Merge remote-tracking branch 'origin/main' into feature-sumu-bill-offset-after-sales
# Conflicts: # app/Http/Controllers/Business/Pages.php # modules/EcommerceFinance/View/BillOffsetPageData.php # modules/EcommerceFinance/View/OrderDataPageData.php # resources/views/business/pages/order-data/bill-offset.blade.php # tests/Feature/Business/PagesTest.php # tests/Unit/EcommerceFinance/BillOffsetMirrorRepositoryTest.php
This commit is contained in:
commit
00935c1aa3
@ -1,69 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 账单冲减页面(500112 冲减收入)专用同步命令。
|
||||
*
|
||||
* 现有 business-mirror:sync-tables 走通用调度,本命令只盯这一个 dataset,
|
||||
* 调度粒度更细、不被 business_data.datasets 中的 placement 策略过滤掉。
|
||||
* 镜像表 ops_jst_original_bill_info、列、索引、watermark 已在
|
||||
* config/upstream_mirrors.php 配置;这里只负责按稳定游标续传。
|
||||
*/
|
||||
class SyncBillOffsetMirror extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:sync-bill-offset
|
||||
{--full : 执行或继续首次全量同步}
|
||||
{--batch= : 每批最多读取行数,默认取 config(upstream_mirrors.table_sync.batch_size)}
|
||||
{--max-batches= : 本轮最多批次数,0 表示直到完成}';
|
||||
|
||||
protected $description = '将经营分析副本库的账单冲减(500112 冲减收入)数据续传到共享业务镜像库';
|
||||
|
||||
private const DATASET = 'operations_replica.rpt.jst_originalBillInfo';
|
||||
|
||||
public function handle(BusinessMirrorSynchronizer $synchronizer): int
|
||||
{
|
||||
if (! (bool) config('upstream_mirrors.refresh_enabled', false) && ! $this->option('full')) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $synchronizer->sync(
|
||||
self::DATASET,
|
||||
(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('账单冲减共享镜像同步失败。', [
|
||||
'dataset' => self::DATASET,
|
||||
'exception' => $exception::class,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
$this->error('账单冲减共享镜像同步失败,已保留上一版共享数据。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($result['status'] === 'requires_full_sync') {
|
||||
$this->warn('账单冲减共享镜像尚未执行首次全量同步,请显式使用 --full。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'账单冲减镜像(%s):%s,本轮 %d 批,累计处理 %d 行。',
|
||||
self::DATASET,
|
||||
$result['status'] === 'succeeded' ? '同步完成' : '同步进行中',
|
||||
$result['batches'],
|
||||
$result['rows'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -50,13 +50,6 @@ class Kernel extends ConsoleKernel
|
||||
->runInBackground();
|
||||
}
|
||||
|
||||
// 账单冲减页面(500112 冲减收入)每 30 分钟续传到共享业务镜像。
|
||||
// 走专用命令避开 business_data.datasets 的 clickhouse 放置策略;
|
||||
// refresh_enabled 仅在生产刷新节点开启,本地 schedule 不会真的拉上游。
|
||||
$schedule->command('business-mirror:sync-bill-offset --max-batches=2')
|
||||
->everyThirtyMinutes()
|
||||
->withoutOverlapping(15)
|
||||
->runInBackground();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -22,9 +22,11 @@ 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\View\BillOffsetPageData;
|
||||
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
|
||||
@ -254,17 +256,13 @@ class Pages extends Controller
|
||||
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'order-data');
|
||||
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'bill-offset'), 403, '当前账号没有该页面权限。');
|
||||
|
||||
$orderData = app(OrderDataPageData::class);
|
||||
$filters = $orderData->afterSalesFilters($request->query());
|
||||
$limit = 100;
|
||||
try {
|
||||
$result = $orderData->afterSalesRows($filters, 1, $limit);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
abort(503, '订单售后费用数据暂时无法读取,请稍后重试。');
|
||||
}
|
||||
$repository = app(BillOffsetPageData::class);
|
||||
$filters = $request->query();
|
||||
$result = $repository->data($filters, 1, 100);
|
||||
abort_if($result['billOffsetError'] !== null, 503, $result['billOffsetError']);
|
||||
$filters = $result['billOffsetFilters'];
|
||||
|
||||
return response()->streamDownload(function () use ($orderData, $filters, $result, $limit): void {
|
||||
return response()->streamDownload(function () use ($repository, $filters, $result): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
if ($csv === false) {
|
||||
throw new \RuntimeException('无法创建账单冲减导出流。');
|
||||
@ -280,25 +278,28 @@ class Pages extends Controller
|
||||
$current = $result;
|
||||
$page = 1;
|
||||
do {
|
||||
foreach (($current['rows'] ?? []) as $row) {
|
||||
foreach ($current['billOffsetRows'] as $row) {
|
||||
fputcsv($csv, array_map(
|
||||
function (string $field) use ($row): string {
|
||||
$value = $row[$field] ?? null;
|
||||
if ($field === 'account_500112' && $value !== null && $value !== '') {
|
||||
$value = number_format((float) $value, 2, '.', '');
|
||||
$value = data_get($row, $field);
|
||||
|
||||
if ($field === 'account_500112') {
|
||||
return $value === null || $value === '' ? '' : number_format((float) $value, 2, '.', '');
|
||||
}
|
||||
|
||||
return $this->csvText($value);
|
||||
return $field === 'shop_name'
|
||||
? $this->csvText($value)
|
||||
: $this->csvIdentifier($value);
|
||||
},
|
||||
array_keys($labels),
|
||||
));
|
||||
}
|
||||
$hasMore = (bool) ($current['has_more'] ?? false);
|
||||
if ($hasMore) {
|
||||
$page++;
|
||||
$current = $orderData->afterSalesRows($filters, $page, $limit);
|
||||
if (! $current['billOffsetRows']->hasMorePages()) break;
|
||||
$current = $repository->dataForNormalizedFilters($filters, ++$page, 100);
|
||||
if ($current['billOffsetError'] !== null) {
|
||||
throw new \RuntimeException('账单冲减导出未完成,请重试。');
|
||||
}
|
||||
} while ($hasMore);
|
||||
} while (true);
|
||||
fclose($csv);
|
||||
}, '账单冲减_' . now()->format('Ymd_His') . '.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
@ -1892,6 +1893,11 @@ class Pages extends Controller
|
||||
'initialized' => false,
|
||||
'last_succeeded_at' => null,
|
||||
],
|
||||
'productCatalogWorkPlatformPriceSyncStatus' => $result['work_platform_price_sync_status'] ?? [
|
||||
'code' => 'unavailable',
|
||||
'initialized' => false,
|
||||
'last_succeeded_at' => null,
|
||||
],
|
||||
];
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
@ -1923,7 +1929,8 @@ class Pages extends Controller
|
||||
fputcsv($csv, [
|
||||
'ID', '款式编码', '商品编码', '商品名称', '商品简称', '规格 / 颜色',
|
||||
'品牌', '类目', '供应商', '销售价', '成本价', '普通商品采购价',
|
||||
'1688代发单价', '数据来源', '市场价', '商品状态', '库存同步', '业务更新时间',
|
||||
'工作平台价格', '工作平台价格生效日期', '1688代发单价',
|
||||
'数据来源', '市场价', '商品状态', '库存同步', '业务更新时间',
|
||||
]);
|
||||
|
||||
foreach ($this->productCatalog->exportRows($filters) as $row) {
|
||||
@ -1950,6 +1957,8 @@ class Pages extends Controller
|
||||
$row->sale_price,
|
||||
$row->cost_price,
|
||||
$row->purchase_price,
|
||||
$row->work_platform_price ?? null,
|
||||
$this->workPlatformPriceDate($row->work_platform_price_effect_time ?? null),
|
||||
$row->purchase_order_unit_price,
|
||||
implode(';', $row->data_sources),
|
||||
$row->market_price,
|
||||
@ -1966,6 +1975,19 @@ class Pages extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
private function workPlatformPriceDate(mixed $value): string
|
||||
{
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($value)->format('Y-m-d');
|
||||
} catch (Throwable) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
public function exportCourierInformation(Request $request): StreamedResponse
|
||||
{
|
||||
abort_unless($this->courierInformation->available() && $this->courierInformation->ready(), 503);
|
||||
@ -2110,6 +2132,11 @@ class Pages extends Controller
|
||||
'initialized' => false,
|
||||
'last_succeeded_at' => null,
|
||||
],
|
||||
'productCatalogWorkPlatformPriceSyncStatus' => [
|
||||
'code' => 'unavailable',
|
||||
'initialized' => false,
|
||||
'last_succeeded_at' => null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -48,6 +48,7 @@ return [
|
||||
'operations_replica.rpt.fys_shopInfo' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '店铺资料/店铺报表'],
|
||||
'operations_replica.rpt.fys_TrademarkInfo' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '商标资料'],
|
||||
'operations_replica.rpt.jst_sku' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '商品资料/售后费用'],
|
||||
'jiansun.dbo.pm_priceAdjustment' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '普通商品资料/工作平台价格'],
|
||||
'operations_replica.rpt.jst_warehouseInfo' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '售后费用'],
|
||||
'operations_replica.rpt.jst_shopInfo' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '预留聚水潭店铺维表'],
|
||||
|
||||
@ -73,8 +74,8 @@ return [
|
||||
'activation_group' => 'order-analysis',
|
||||
],
|
||||
'operations_replica.rpt.jst_originalBillInfo' => [
|
||||
'role' => 'fact', 'target_store' => 'clickhouse', 'consumer' => '通用原始账单(当前无页面直读)',
|
||||
'partition_column' => 'fee_time', 'sync' => 'after_columnar_activation',
|
||||
'role' => 'fact', 'target_store' => 'clickhouse', 'consumer' => '店铺收款账户·淘系支付宝账单',
|
||||
'partition_column' => 'fee_time', 'sync' => 'always',
|
||||
],
|
||||
|
||||
// This 20M+ row mapping is not queried by the current promotion page.
|
||||
@ -108,6 +109,9 @@ return [
|
||||
'jiansun.dbo.pm_unOrderDetails' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '未定价明细'],
|
||||
'jiansun.dbo.finance_sampleDeduction' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '样品扣款'],
|
||||
'jiansun.dbo.fys_express_courier_settlement' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '代发快递'],
|
||||
'jiansun.dbo.jst_order_express_details' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账物流明细'],
|
||||
'jiansun.dbo.Jst_SendData_View_extension' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账商品与收货区域'],
|
||||
'jiansun.dbo.jst_salesOutboundInfo' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账出库运费'],
|
||||
'jiansun.dbo.finance_boardDeduction' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '板材扣款'],
|
||||
'jiansun.dbo.pm_plainPlateData' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '素板资料'],
|
||||
'jiansun.dbo.pm_boardPurchasePrice' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '板材采购价'],
|
||||
|
||||
@ -128,6 +128,12 @@ return [
|
||||
'stale_after_minutes' => (int) env('COURIER_INFORMATION_MIRROR_STALE_MINUTES', 60),
|
||||
],
|
||||
|
||||
// 工作平台采购价格按 SKU 补充到普通商品资料;页面只读取共享镜像。
|
||||
'work_platform_prices' => [
|
||||
'dataset_key' => 'jiansun.dbo.pm_priceAdjustment',
|
||||
'target_table' => 'js_pm_price_adjustment',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 上游原表镜像目录
|
||||
@ -240,6 +246,7 @@ return [
|
||||
['Id'],
|
||||
['shop_id', 'fee_time', 'Id'],
|
||||
['fee_time', 'Id'],
|
||||
['channel', 'fee_time', 'Id', 'inout_type', 'amount'],
|
||||
],
|
||||
],
|
||||
|
||||
@ -260,6 +267,7 @@ return [
|
||||
],
|
||||
'indexes' => [
|
||||
['work_type', 'id'],
|
||||
['work_type', 'create_time', 'id'],
|
||||
['create_time', 'id'],
|
||||
],
|
||||
],
|
||||
@ -279,6 +287,7 @@ return [
|
||||
],
|
||||
'indexes' => [
|
||||
['id'],
|
||||
['main_order_no', 'id'],
|
||||
],
|
||||
],
|
||||
'operations_replica.rpt.work_order_form_config' => [
|
||||
@ -312,6 +321,7 @@ return [
|
||||
],
|
||||
'indexes' => [
|
||||
['sort', 'id'],
|
||||
['main_order_no', 'sort', 'id'],
|
||||
],
|
||||
],
|
||||
|
||||
@ -404,6 +414,33 @@ return [
|
||||
'jiansun.dbo.pm_unOrderDetails' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_unOrderDetails', 'target' => 'js_pm_unorder_details', 'keys' => ['Id']],
|
||||
'jiansun.dbo.finance_sampleDeduction' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'finance_sampleDeduction', 'target' => 'js_finance_sample_deduction', 'keys' => ['id'], 'watermark' => 'finishTime'],
|
||||
'jiansun.dbo.fys_express_courier_settlement' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_express_courier_settlement', 'target' => 'js_express_courier_settlement', 'keys' => ['Id'], 'watermark' => 'modify_time'],
|
||||
'jiansun.dbo.jst_order_express_details' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_order_express_details',
|
||||
'target' => 'js_jst_order_express_details', 'keys' => ['id'], 'watermark' => 'updatedAt',
|
||||
'indexes' => [['send_time', 'id'], ['inner_order_id', 'id'], ['id', 'logistic_id']],
|
||||
'columns' => [
|
||||
'id', 'updatedAt', 'inner_order_id', 'so_id', 'logistic_id', 'logistic_company_name',
|
||||
'logistic_status_name', 'platform_name', 'shop_name', 'warehouse_name', 'send_time',
|
||||
'weight', 'f_weight',
|
||||
],
|
||||
],
|
||||
'jiansun.dbo.Jst_SendData_View_extension' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'Jst_SendData_View_extension',
|
||||
'target' => 'js_jst_send_data_view_extension', 'keys' => ['Id'], 'watermark' => null,
|
||||
'indexes' => [['so_id', 'Id'], ['io_id', 'Id']],
|
||||
'columns' => [
|
||||
'Id', 'io_id', 'qty', 'l', 'w', 'h', 'sku_ids', 'sku_name', 'so_id', 'shop_site',
|
||||
'shop_name', 'shop_i_id', 'supplier_name', 'receiver_state', 'receiver_city',
|
||||
'receiver_district', 'expressName', 'wareHoursName', 'volume', 'weight',
|
||||
],
|
||||
],
|
||||
// 只同步运费对账需要的出库字段,不复制收货电话、买家信息等敏感列。
|
||||
'jiansun.dbo.jst_salesOutboundInfo' => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_salesOutboundInfo',
|
||||
'target' => 'js_jst_sales_outbound_info', 'keys' => ['Id'], 'watermark' => 'updateTime',
|
||||
'indexes' => [['io_id', 'Id']],
|
||||
'columns' => ['Id', 'io_id', 'modified', 'updateTime', 'f_weight', 'weight', 'f_freight', 'freight', 'pay_date', 'volume', 'f_volume'],
|
||||
],
|
||||
'jiansun.dbo.finance_boardDeduction' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'finance_boardDeduction', 'target' => 'js_finance_board_deduction', 'keys' => ['id']],
|
||||
'jiansun.dbo.pm_plainPlateData' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_plainPlateData', 'target' => 'js_pm_plain_plate_data', 'keys' => ['Id'], 'watermark' => 'createTime'],
|
||||
'jiansun.dbo.pm_boardPurchasePrice' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pm_boardPurchasePrice', 'target' => 'js_pm_board_purchase_price', 'keys' => ['id'], 'watermark' => 'updateTime'],
|
||||
@ -418,6 +455,21 @@ 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 取当前已生效的最新价格。
|
||||
'jiansun.dbo.pm_priceAdjustment' => [
|
||||
'source_key' => 'jiansun',
|
||||
'schema' => 'dbo',
|
||||
'object' => 'pm_priceAdjustment',
|
||||
'target' => 'js_pm_price_adjustment',
|
||||
'keys' => ['Id'],
|
||||
'watermark' => null,
|
||||
'columns' => ['Id', 'skuCode', 'purchasePrice', 'effectTime'],
|
||||
'indexes' => [
|
||||
['skuCode(64)', 'effectTime', 'Id'],
|
||||
['effectTime', 'Id'],
|
||||
],
|
||||
],
|
||||
|
||||
// 工作平台 2.0:快递资料及其网点、打款账户。页面只读取共享镜像,
|
||||
// 首次使用前需由刷新节点显式执行对应数据集的 --full 同步。
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
<?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 const INDEXES = [
|
||||
'ops_work_order_main' => ['idx_work_order_type_created', ['work_type', 'create_time', 'id']],
|
||||
'ops_work_order_sub' => ['idx_work_order_sub_parent', ['main_order_no', 'id']],
|
||||
'ops_work_order_form_value' => ['idx_work_order_value_parent', ['main_order_no', 'sort', 'id']],
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$schema = Schema::connection($connection);
|
||||
$db = DB::connection($connection);
|
||||
foreach (self::INDEXES as $table => [$name, $columns]) {
|
||||
if (! $schema->hasTable($table) || $schema->hasIndex($table, $name)) continue;
|
||||
if (in_array('main_order_no', $columns, true)) {
|
||||
$length = $db->getDriverName() === 'sqlite' ? 'length' : 'char_length';
|
||||
if ($db->table($table)->whereRaw($length . '(main_order_no) > 100')->exists()) {
|
||||
throw new RuntimeException('工单关联编号超过上游 varchar(100) 定义,已停止变更。');
|
||||
}
|
||||
$schema->table($table, static fn (Blueprint $t) => $t->string('main_order_no', 100)->nullable()->change());
|
||||
}
|
||||
$schema->table($table, static fn (Blueprint $t) => $t->index($columns, $name));
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
|
||||
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
foreach (self::INDEXES as $table => [$name, $columns]) {
|
||||
if (! $schema->hasTable($table) || ! $schema->hasIndex($table, $name)) continue;
|
||||
$schema->table($table, static fn (Blueprint $t) => $t->dropIndex($name));
|
||||
if (in_array('main_order_no', $columns, true)) {
|
||||
$schema->table($table, static fn (Blueprint $t) => $t->longText('main_order_no')->nullable()->change());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
|
||||
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
if ($schema->hasTable('business_mirror_table_states') && ! $schema->hasColumn('business_mirror_table_states', 'mirror_row_count_is_exact')) {
|
||||
$schema->table('business_mirror_table_states', static fn (Blueprint $t) => $t->boolean('mirror_row_count_is_exact')->default(false));
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
|
||||
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
if ($schema->hasTable('business_mirror_table_states') && $schema->hasColumn('business_mirror_table_states', 'mirror_row_count_is_exact')) {
|
||||
$schema->table('business_mirror_table_states', static fn (Blueprint $t) => $t->dropColumn('mirror_row_count_is_exact'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,40 @@
|
||||
<?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 const TABLE = 'js_jst_order_express_details';
|
||||
private const INDEX = 'idx_courier_detail_id_waybill';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$schema = Schema::connection($connection);
|
||||
if (! $schema->hasTable(self::TABLE) || $schema->hasIndex(self::TABLE, self::INDEX)) return;
|
||||
$db = DB::connection($connection);
|
||||
$length = $db->getDriverName() === 'sqlite' ? 'length' : 'char_length';
|
||||
if ($db->table(self::TABLE)->whereRaw($length . '(logistic_id) > 100')->exists()) {
|
||||
throw new RuntimeException('快递单号超过上游 varchar(100) 定义,已停止变更。');
|
||||
}
|
||||
$schema->table(self::TABLE, static function (Blueprint $table): void {
|
||||
$table->string('logistic_id', 100)->nullable()->change();
|
||||
$table->index(['id', 'logistic_id'], self::INDEX);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
|
||||
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
if (! $schema->hasTable(self::TABLE) || ! $schema->hasIndex(self::TABLE, self::INDEX)) return;
|
||||
$schema->table(self::TABLE, static function (Blueprint $table): void {
|
||||
$table->dropIndex(self::INDEX);
|
||||
$table->longText('logistic_id')->nullable()->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private const TABLE = 'ops_jst_original_bill_info';
|
||||
private const INDEX = 'idx_original_bill_channel_time';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$schema = Schema::connection($connection);
|
||||
if (! $schema->hasTable(self::TABLE) || $schema->hasIndex(self::TABLE, self::INDEX)) return;
|
||||
$schema->table(self::TABLE, static function (Blueprint $table): void {
|
||||
$table->index(['channel', 'fee_time', 'Id', 'inout_type', 'amount'], self::INDEX);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
|
||||
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
if (! $schema->hasTable(self::TABLE) || ! $schema->hasIndex(self::TABLE, self::INDEX)) return;
|
||||
$schema->table(self::TABLE, static function (Blueprint $table): void {
|
||||
$table->dropIndex(self::INDEX);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -22,6 +22,7 @@
|
||||
| 中央同步接入 | 店铺维度报表从 `jiansun.dbo.jst_originalBillInfo` 和 `dbo.fys_shopInfo_flow` 自动、可断点同步;不再依赖开发者电脑、SQLite 或人工 XLSX。 |
|
||||
| 已完成 | `operations_replica/rpt.jst_originalBillInfo` 已进入通用原始账单镜像 `ops_jst_original_bill_info`;保留当前 19 个业务字段,按 `Id` 断点全量、按 `updateTime` 重叠增量。 |
|
||||
| 页面已切换 | 店铺收款账单的淘系支付宝账户读取 `operations_replica.rpt.jst_originalBillInfo`,并关联 `rpt.jst_shopInfo` 展示店铺;分页、筛选、汇总和导出统一读取共享业务镜像。 |
|
||||
| 本次接入 | 普通商品资料的工作平台采购价格接入 `jiansun.dbo.pm_priceAdjustment`(镜像 `js_pm_price_adjustment`):按 `skuCode` 关联 `jst_sku.sku_id`,取 `effectTime` 不晚于当前时间的最新 `purchasePrice`,并在页面和导出中标记“工作平台价格”;源表没有可靠更新时间,按 `Id` 稳定键追加,首次全量完成前只显示普通商品自身采购价。 |
|
||||
|
||||
## 经营分析副本库(operations_replica)
|
||||
|
||||
@ -55,11 +56,13 @@
|
||||
| 店铺收款账单旧来源(已停用) | `dbo.alipay_Bill_Detail` |
|
||||
| 营销账户 | `dbo.tmall_spread_bills`、`dbo.pdd_spread_bills`、`dbo.alibaba_bill`、`dbo.jd_spreadBills`、`dbo.jd_costBills` |
|
||||
| 供应商应付 | `dbo.finance_purchaseReturn`、`dbo.finance_invoiceDeduction`、`dbo.fys_supplierPaymentSummary`、`dbo.finance_advanceLoans`、`dbo.pm_unOrderDetails`、`dbo.finance_sampleDeduction`、`dbo.fys_express_courier_settlement` |
|
||||
| 快递运费对账明细 | `dbo.jst_order_express_details`、`dbo.Jst_SendData_View_extension`、`dbo.jst_salesOutboundInfo` |
|
||||
| 安装明细 | `dbo.fys_Ticketing_install_view`、`dbo.fys_Ticketing_install_item` |
|
||||
| 板材 | `dbo.finance_boardDeduction`、`dbo.pm_plainPlateData`、`dbo.pm_boardPurchasePrice`、`dbo.pm_boardPriceChangeImport`、`dbo.pm_boardApplyDetail` |
|
||||
| 封边条 | `dbo.pm_edgeBandData`、`dbo.pm_edgeBandApplyDetail`、`dbo.pm_edgeBandProfitInfo`、`dbo.finance_edgeBandDeductio`、`dbo.pm_averageUnitPrice` |
|
||||
| 库存与货款对账 | `dbo.pm_boardInventoryDetail_snapshot`、`dbo.fys_supplierGoods_sttlement_purchase`、`dbo.fys_supplierGoods_sttlement_sale`、`dbo.pm_purchaseIn` |
|
||||
| 采购单价 | `dbo.jst_purchase`、`dbo.jst_purchaseItem`(受控字段联表镜像,不复制其他采购业务字段) |
|
||||
| 工作平台采购价格 | `dbo.pm_priceAdjustment`(`Id` 稳定键;按 `skuCode` 匹配 `jst_sku.sku_id`,页面取当前已生效的最新 `purchasePrice` / `effectTime`) |
|
||||
| 快递资料 | `dbo.wp_express`、`dbo.wp_express_expNet`、`dbo.wp_express_account`、`dbo.wp_express_extension` |
|
||||
| 聚水潭小额打款工单 | `dbo.jst_workOrderInfo`(稳定键 `Id`、增量字段 `updateTime`)与 `dbo.jst_small_payment_order_map`(稳定键 `woNo`、增量字段 `updated_at`) |
|
||||
|
||||
@ -76,7 +79,13 @@
|
||||
7. 普通商品的采购单价同步以采购主表 `updateTime` 加明细 `Id` 作为稳定游标;页面按商品编码选择最近完成的 1688 一键代发价格。同步保存全部状态,使完成单后续取消时也能撤回旧价格;首次全量前只显示普通商品自身采购价。
|
||||
8. 聚水潭官方接口尚未接入时,普通商品历史残留排除由负责人提供的最新完整 XLSX 导出刷新。只排除“导出生成前已创建、但导出中缺失”的镜像行,导出生成后的新增商品保持可见。导入使用新同步代次,只有完整解析成功才原子替换排除表;中断或错误保留上一版。后续接入聚水潭官方“普通商品资料查询”及删除/恢复记录后,由官方接口取代人工导出校准。
|
||||
9. 店铺维度报表是已批准的中央专用数据集:生产节点按 `Id` 断点推进并回看最近 50000 行;原始账单同步后增量维护按店铺/日期汇总的 `store_report_daily_rows`,页面不得现场扫描千万级原始账单。开发机不运行刷新 worker。
|
||||
10. `operations_replica.rpt.jst_originalBillInfo` 是供多个业务功能复用的原始账单镜像。首次同步由 Codex 备份后自主安排在生产同步节点低峰期分批续传,无需另行授权;页面请求和普通开发机不启动刷新 worker。同步完成后,开发者通过固定 Repository 查询 `ops_jst_original_bill_info`,不得各自创建整表快照。
|
||||
10. `operations_replica.rpt.jst_originalBillInfo` 是供多个业务功能复用的原始账单镜像。首次同步由 Codex 备份后自主安排在生产同步节点低峰期分批续传,无需另行授权;页面请求和普通开发机不启动刷新 worker。同步完成后,开发者通过固定 Repository 查询 `ops_jst_original_bill_info`,不得各自创建整表快照。 淘系支付宝账户账单当前仍读取此 MariaDB 镜像,统一调度须持续增量,不因 ClickHouse 尚未启用而暂停。账单冲减则复用“订单数据 → 原始数据 → 订单售后费用明细”,不按该表费用名称推断 500112。
|
||||
|
||||
工作平台采购价格首次全量需在备份和负责人授权后单独执行:
|
||||
|
||||
```bash
|
||||
php artisan business-mirror:sync-tables jiansun.dbo.pm_priceAdjustment --full
|
||||
```
|
||||
|
||||
## 后续切换顺序
|
||||
|
||||
|
||||
@ -7,6 +7,8 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\AccountBillColumns;
|
||||
use Modules\EcommerceFinance\Models\AccountBill;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
|
||||
use RuntimeException;
|
||||
|
||||
class AccountBills extends Controller
|
||||
{
|
||||
@ -25,6 +27,12 @@ class AccountBills extends Controller
|
||||
abort(404, '未知的账户类型。');
|
||||
}
|
||||
|
||||
if ($billType === 'douyin') {
|
||||
return $request->boolean('export')
|
||||
? $this->export($request, $billType)
|
||||
: $this->douyinIndex($request);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'store_name' => ['nullable', 'string', 'max:255'],
|
||||
'bill_item' => ['nullable', 'string', 'max:255'],
|
||||
@ -72,7 +80,7 @@ class AccountBills extends Controller
|
||||
$query->orderBy($sort, $dir);
|
||||
|
||||
if ($request->boolean('export')) {
|
||||
return $this->export($request, $query, $billType);
|
||||
return $this->exportQuery($request, $query, $billType);
|
||||
}
|
||||
|
||||
$perPage = min(max((int) $request->get('limit', 10), 1), 100);
|
||||
@ -102,11 +110,43 @@ class AccountBills extends Controller
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
private function export(Request $request, $query, string $billType)
|
||||
public function export(Request $request, string $bill_type)
|
||||
{
|
||||
$this->authorizeStoreReceivingBills();
|
||||
|
||||
abort_unless(AccountBillColumns::has($bill_type), 404, '未知的账户类型。');
|
||||
|
||||
if ($bill_type === 'douyin') {
|
||||
return $this->exportDouyin($request);
|
||||
}
|
||||
|
||||
$filters = $request->validate([
|
||||
'store_name' => ['nullable', 'string', 'max:255'],
|
||||
'bill_item' => ['nullable', 'string', 'max:255'],
|
||||
'start' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'],
|
||||
'keyword' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
$query = $this->filteredQuery($request, $bill_type);
|
||||
$sortable = ['occurred_at', 'income', 'expense', 'balance', 'record_code'];
|
||||
$sort = in_array($request->get('sort'), $sortable, true) ? $request->get('sort') : 'occurred_at';
|
||||
$dir = $request->get('dir') === 'asc' ? 'asc' : 'desc';
|
||||
$query->orderBy($sort, $dir);
|
||||
|
||||
return $this->exportQuery($request, $query, $bill_type);
|
||||
}
|
||||
|
||||
private function exportQuery(Request $request, $query, string $billType)
|
||||
{
|
||||
$definition = AccountBillColumns::for($billType);
|
||||
$available = collect($definition['columns'] ?? [])->keyBy('field');
|
||||
$requested = $request->input('columns', []);
|
||||
if (is_string($requested)) {
|
||||
$requested = array_filter(array_map('trim', explode(',', $requested)));
|
||||
}
|
||||
if (! is_array($requested)) {
|
||||
$requested = [];
|
||||
}
|
||||
$columns = $requested === []
|
||||
? $available->values()
|
||||
: collect($requested)->unique()->map(fn (string $field) => $available->get($field))->filter()->values();
|
||||
@ -126,7 +166,10 @@ class AccountBills extends Controller
|
||||
})->all());
|
||||
}
|
||||
fclose($csv);
|
||||
}, $billType . '_account_bills.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}, $billType . '_account_bills.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
private function columnValue(AccountBill $row, string $field): mixed
|
||||
@ -148,6 +191,175 @@ class AccountBills extends Controller
|
||||
return preg_match('/^[=+\-@]/', $text) ? "'" . $text : $text;
|
||||
}
|
||||
|
||||
private function filteredQuery(Request $request, string $billType)
|
||||
{
|
||||
$query = AccountBill::query()
|
||||
->where('company_id', company_id())
|
||||
->where('bill_type', $billType);
|
||||
|
||||
if ($store = $request->get('store_name')) {
|
||||
$query->where('store_name', $store);
|
||||
}
|
||||
|
||||
if ($item = $request->get('bill_item')) {
|
||||
$query->where('bill_item', $item);
|
||||
}
|
||||
|
||||
if ($start = $request->get('start')) {
|
||||
$query->where('occurred_at', '>=', $start);
|
||||
}
|
||||
|
||||
if ($end = $request->get('end')) {
|
||||
$query->where('occurred_at', '<=', $end . ' 23:59:59');
|
||||
}
|
||||
|
||||
if ($keyword = $request->get('keyword')) {
|
||||
$query->where(function ($q) use ($keyword): void {
|
||||
$like = '%' . $keyword . '%';
|
||||
$q->where('record_code', 'like', $like)
|
||||
->orWhere('order_no', 'like', $like)
|
||||
->orWhere('remark', 'like', $like)
|
||||
->orWhere('bill_item', 'like', $like);
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function douyinIndex(Request $request)
|
||||
{
|
||||
$filters = $this->douyinFilters($request);
|
||||
$sort = $this->sort($request);
|
||||
$direction = $this->direction($request);
|
||||
$page = max((int) $request->get('page', 1), 1);
|
||||
$perPage = min(max((int) $request->get('limit', 10), 1), 100);
|
||||
|
||||
try {
|
||||
$data = app(StoreReceivingDouyinBillMirrorRepository::class)->paginate(
|
||||
$filters,
|
||||
$sort,
|
||||
$direction,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => '抖音账户账单共享业务镜像正在首次同步,完成后将自动显示。',
|
||||
'data' => [],
|
||||
'total' => 0,
|
||||
'current_page' => 1,
|
||||
'last_page' => 1,
|
||||
'per_page' => $perPage,
|
||||
'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0],
|
||||
'columns' => AccountBillColumns::for('douyin'),
|
||||
], 503);
|
||||
}
|
||||
|
||||
$data['columns'] = AccountBillColumns::for('douyin');
|
||||
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
private function exportDouyin(Request $request)
|
||||
{
|
||||
$filters = $this->douyinFilters($request);
|
||||
$definition = AccountBillColumns::for('douyin');
|
||||
$columns = collect($definition['columns'] ?? []);
|
||||
$requested = $request->query('columns', []);
|
||||
if (is_string($requested)) {
|
||||
$requested = array_filter(array_map('trim', explode(',', $requested)));
|
||||
}
|
||||
if (! is_array($requested)) {
|
||||
$requested = [];
|
||||
}
|
||||
$requested = collect($requested)->map(static fn (string $field): string => trim($field))->filter()->unique()->values();
|
||||
if ($requested->isEmpty()) {
|
||||
$selected = $columns;
|
||||
} else {
|
||||
$columnsByField = $columns->keyBy('field');
|
||||
$selected = $requested
|
||||
->map(static fn (string $field) => $columnsByField->get($field))
|
||||
->filter(static fn ($column): bool => is_array($column))
|
||||
->values();
|
||||
abort_if($selected->isEmpty(), 422, '未选择有效的导出字段。');
|
||||
}
|
||||
|
||||
$sort = $this->sort($request);
|
||||
$direction = $this->direction($request);
|
||||
try {
|
||||
$rows = app(StoreReceivingDouyinBillMirrorRepository::class)
|
||||
->exportRows($filters, $sort, $direction);
|
||||
} catch (RuntimeException $exception) {
|
||||
if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return response()->json(['message' => '抖音账户账单共享业务镜像正在首次同步,完成后将自动导出。'], 503);
|
||||
}
|
||||
|
||||
$headers = $selected->pluck('label')->all();
|
||||
|
||||
return response()->streamDownload(function () use ($rows, $selected, $headers): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
fputs($csv, "\xEF\xBB\xBF");
|
||||
fputcsv($csv, $headers);
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($csv, $selected->map(
|
||||
fn (array $column): string => $this->exportArrayValue($row, $column)
|
||||
)->all());
|
||||
}
|
||||
fclose($csv);
|
||||
}, 'douyin_account_bills.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private function douyinFilters(Request $request): array
|
||||
{
|
||||
$filters = $request->validate([
|
||||
'store_name' => ['nullable', 'string', 'max:255'],
|
||||
'bill_item' => ['nullable', 'string', 'max:255'],
|
||||
'start' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'],
|
||||
'keyword' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
return array_map(static fn (mixed $value): string => trim((string) $value), $filters);
|
||||
}
|
||||
|
||||
private function sort(Request $request): string
|
||||
{
|
||||
return in_array($request->get('sort'), ['occurred_at', 'income', 'expense', 'balance', 'record_code'], true)
|
||||
? (string) $request->get('sort')
|
||||
: 'occurred_at';
|
||||
}
|
||||
|
||||
private function direction(Request $request): string
|
||||
{
|
||||
return $request->get('dir') === 'asc' ? 'asc' : 'desc';
|
||||
}
|
||||
|
||||
private function exportArrayValue(array $row, array $column): string
|
||||
{
|
||||
$field = (string) ($column['field'] ?? '');
|
||||
$value = str_starts_with($field, 'payload.')
|
||||
? data_get($row['payload'] ?? [], substr($field, 8))
|
||||
: data_get($row, $field);
|
||||
|
||||
$text = trim((string) ($value ?? ''));
|
||||
if (preg_match('/^\d{12,}$/', $text) === 1) {
|
||||
return '="' . $text . '"';
|
||||
}
|
||||
|
||||
return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text;
|
||||
}
|
||||
|
||||
public function show(AccountBill $bill)
|
||||
{
|
||||
$this->authorizeStoreReceivingBills();
|
||||
|
||||
@ -104,7 +104,14 @@ class AlipayBills extends Controller
|
||||
unset($filters['columns']);
|
||||
|
||||
try {
|
||||
$rows = $repository->exportRows(array_map(static fn (mixed $value): string => trim((string) $value), $filters));
|
||||
$sortable = ['record_code', 'occurred_at', 'income', 'expense', 'balance'];
|
||||
$sort = in_array($request->get('sort'), $sortable, true) ? $request->get('sort') : 'occurred_at';
|
||||
$dir = $request->get('dir') === 'asc' ? 'asc' : 'desc';
|
||||
$rows = $repository->exportRows(
|
||||
array_map(static fn (mixed $value): string => trim((string) $value), $filters),
|
||||
$sort,
|
||||
$dir,
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') {
|
||||
throw $exception;
|
||||
|
||||
@ -1,280 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
|
||||
/** 账单冲减只读共享业务镜像。 */
|
||||
class BillOffsetMirrorRepository
|
||||
{
|
||||
public const DATASET = 'operations_replica.rpt.jst_originalBillInfo';
|
||||
|
||||
public function __construct(
|
||||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||||
private readonly GenericBusinessMirrorRepository $mirror,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{data:list<array<string,mixed>>,total:int,current_page:int,last_page:int,per_page:int,summary:array{amount:float,count:int},sync:array<string,mixed>}
|
||||
*/
|
||||
public function paginate(array $filters, int $page, int $perPage): array
|
||||
{
|
||||
$dataset = $this->datasets->get(self::DATASET);
|
||||
if (! $this->mirror->ready($dataset)) {
|
||||
throw new RuntimeException('bill_offset_mirror_not_ready');
|
||||
}
|
||||
|
||||
$page = max($page, 1);
|
||||
$perPage = min(max($perPage, 1), 100);
|
||||
|
||||
// 只有店铺名称、订单号(内部订单号)和款式编码这几类过滤必须依赖关联表;
|
||||
// 只按日期/单据号/商品编码查时,统计与分页都不必把千万级账单表和订单表
|
||||
// 做全量关联,否则每次翻页都要扫一遍 ops_jst_orders。
|
||||
$withJoin = $this->requiresJoin($filters);
|
||||
|
||||
$aggregate = $this->aggregate($filters, $withJoin);
|
||||
$rows = $this->rows($filters, $page, $perPage, $withJoin);
|
||||
$state = $this->mirror->state($dataset) ?? [];
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
'total' => $aggregate['total'],
|
||||
'current_page' => $page,
|
||||
'last_page' => max((int) ceil($aggregate['total'] / $perPage), 1),
|
||||
'per_page' => $perPage,
|
||||
'summary' => [
|
||||
'amount' => $aggregate['amount'],
|
||||
'count' => $aggregate['total'],
|
||||
],
|
||||
'sync' => [
|
||||
'status' => $state['status'] ?? 'unknown',
|
||||
'last_succeeded_at' => $state['last_succeeded_at'] ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计与分页以前各自跑一遍 count/sum 且都带着关联,
|
||||
* 这里合并成一次聚合,并在不需要关联时退回单表。
|
||||
*
|
||||
* @return array{total:int,amount:float}
|
||||
*/
|
||||
private function aggregate(array $filters, bool $withJoin): array
|
||||
{
|
||||
$query = $withJoin ? $this->query() : $this->billQuery();
|
||||
$this->applyBillOffsetScope($query);
|
||||
$this->applyFilters($query, $filters);
|
||||
|
||||
$row = $query->selectRaw(
|
||||
'COALESCE(SUM(COALESCE(b.amount, 0)), 0) AS amount, COUNT(*) AS aggregate_count'
|
||||
)->first();
|
||||
|
||||
return [
|
||||
'total' => (int) ($row->aggregate_count ?? 0),
|
||||
'amount' => round((float) ($row->amount ?? 0), 2),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前页行。不需要关联时先按 (fee_time, Id) 索引定位本页主键,
|
||||
* 再只把这几行拿去关联订单/明细,避免整页数据参与大表 join + filesort。
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function rows(array $filters, int $page, int $perPage, bool $withJoin): array
|
||||
{
|
||||
$order = static function (Builder $query): void {
|
||||
$query->orderByDesc('b.fee_time')->orderByDesc('b.Id');
|
||||
};
|
||||
|
||||
if ($withJoin) {
|
||||
$query = $this->query();
|
||||
$this->applyBillOffsetScope($query);
|
||||
$this->applyFilters($query, $filters);
|
||||
$order($query);
|
||||
|
||||
return $query->select($this->columns())
|
||||
->forPage($page, $perPage)
|
||||
->get()
|
||||
->map(fn (object $row): array => $this->mapRow($row))
|
||||
->all();
|
||||
}
|
||||
|
||||
$probe = $this->billQuery()->select('b.Id');
|
||||
$this->applyBillOffsetScope($probe);
|
||||
$this->applyFilters($probe, $filters);
|
||||
$order($probe);
|
||||
|
||||
$ids = $probe->forPage($page, $perPage)->pluck('Id')->all();
|
||||
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query = $this->query()->whereIn('b.Id', $ids);
|
||||
$order($query);
|
||||
|
||||
return $query->select($this->columns())
|
||||
->get()
|
||||
->map(fn (object $row): array => $this->mapRow($row))
|
||||
->all();
|
||||
}
|
||||
|
||||
/** 判断本组过滤条件是否有任何一项必须靠关联表才能成立。 */
|
||||
private function requiresJoin(array $filters): bool
|
||||
{
|
||||
$orders = $this->ordersAvailable();
|
||||
|
||||
if ($orders && ($filters['shop_name'] ?? '') !== '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($orders && ($filters['order_no'] ?? '') !== '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $orders && $this->orderItemsAvailable() && ($filters['sku_id'] ?? '') !== '';
|
||||
}
|
||||
|
||||
private function billQuery(): Builder
|
||||
{
|
||||
return $this->mirror->connection()->table('ops_jst_original_bill_info as b');
|
||||
}
|
||||
|
||||
private function query(): Builder
|
||||
{
|
||||
$query = $this->billQuery();
|
||||
$ordersAvailable = $this->ordersAvailable();
|
||||
|
||||
if ($ordersAvailable) {
|
||||
$query->leftJoin('ops_jst_orders as o', function ($join): void {
|
||||
$join->on('o.so_id', '=', 'b.so_id');
|
||||
});
|
||||
}
|
||||
|
||||
if ($ordersAvailable && $this->orderItemsAvailable()) {
|
||||
$query->leftJoin('ops_jst_order_item as i', function ($join): void {
|
||||
$join->on('i.o_id', '=', 'o.o_id')
|
||||
->whereColumn('i.sku_id', 'b.sku_id');
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function hasTable(string $table): bool
|
||||
{
|
||||
return Schema::connection($this->mirror->connection()->getName())->hasTable($table);
|
||||
}
|
||||
|
||||
private function ordersAvailable(): bool
|
||||
{
|
||||
return $this->hasTable('ops_jst_orders');
|
||||
}
|
||||
|
||||
private function orderItemsAvailable(): bool
|
||||
{
|
||||
return $this->hasTable('ops_jst_order_item');
|
||||
}
|
||||
|
||||
private function applyFilters(Builder $query, array $filters): void
|
||||
{
|
||||
if (($filters['from'] ?? '') !== '') {
|
||||
$query->where('b.fee_time', '>=', $filters['from'] . ' 00:00:00');
|
||||
}
|
||||
if (($filters['to'] ?? '') !== '') {
|
||||
$query->where('b.fee_time', '<', date('Y-m-d 00:00:00', strtotime($filters['to'] . ' +1 day')));
|
||||
}
|
||||
if (($filters['order_no'] ?? '') !== '') {
|
||||
$needle = $this->contains((string) $filters['order_no']);
|
||||
$query->where(function (Builder $order) use ($needle): void {
|
||||
$order->whereRaw("CAST(b.so_id AS CHAR) LIKE ? ESCAPE '!'", [$needle])
|
||||
->orWhereRaw("CAST(b.p_auto_id AS CHAR) LIKE ? ESCAPE '!'", [$needle]);
|
||||
if ($this->ordersAvailable()) {
|
||||
$order->orWhereRaw("CAST(o.o_id AS CHAR) LIKE ? ESCAPE '!'", [$needle]);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (($filters['shop_name'] ?? '') !== '') {
|
||||
if ($this->ordersAvailable()) {
|
||||
$query->whereRaw("o.shop_name LIKE ? ESCAPE '!'", [$this->contains((string) $filters['shop_name'])]);
|
||||
} else {
|
||||
$query->whereRaw('1 = 0');
|
||||
}
|
||||
}
|
||||
if (($filters['sku_id'] ?? '') !== '') {
|
||||
$needle = $this->contains((string) $filters['sku_id']);
|
||||
$query->where(function (Builder $sku) use ($needle): void {
|
||||
foreach (['b.sku_id', 'b.shop_sku_id', 'b.shop_i_id'] as $column) {
|
||||
$sku->orWhereRaw($column . " LIKE ? ESCAPE '!'", [$needle]);
|
||||
}
|
||||
if ($this->ordersAvailable() && $this->orderItemsAvailable()) {
|
||||
$sku->orWhereRaw("i.i_id LIKE ? ESCAPE '!'", [$needle]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function applyBillOffsetScope(Builder $query): void
|
||||
{
|
||||
// 原始账单镜像供多个业务复用,这个页面只呈现 500112 冲减收入。
|
||||
// 兼容上游历史上仅保存名称、不带科目编号的“冲减收入”写法。
|
||||
$query->where(function (Builder $offset): void {
|
||||
$offset->whereRaw("b.fee_name LIKE ? ESCAPE '!'", ['%500112%'])
|
||||
->orWhereRaw("b.fee_name LIKE ? ESCAPE '!'", ['%冲减收入%']);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function columns(): array
|
||||
{
|
||||
$columns = [
|
||||
'b.Id as record_id', 'b.fee_time', 'b.fee_name', 'b.inout_type', 'b.amount',
|
||||
'b.so_id as bill_order_no', 'b.sku_id as bill_product_code',
|
||||
'b.shop_sku_id as bill_shop_product_code', 'b.shop_i_id as bill_shop_style_code',
|
||||
];
|
||||
|
||||
if ($this->ordersAvailable()) {
|
||||
$columns = [...$columns, 'o.o_id as internal_order_no', 'o.so_id as online_order_no', 'o.shop_name', 'o.shop_id as shop_code'];
|
||||
}
|
||||
if ($this->ordersAvailable() && $this->orderItemsAvailable()) {
|
||||
$columns = [...$columns, 'i.sku_id as product_code', 'i.i_id as style_code', 'i.shop_sku_id as shop_product_code', 'i.shop_i_id as shop_style_code'];
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function mapRow(object $row): array
|
||||
{
|
||||
return [
|
||||
'record_id' => (string) ($row->record_id ?? ''),
|
||||
'fee_time' => $this->dateTime($row->fee_time ?? null),
|
||||
'fee_name' => $row->fee_name ?? null,
|
||||
'inout_type' => $row->inout_type ?? null,
|
||||
'internal_order_no' => $row->internal_order_no ?? $row->bill_order_no ?? null,
|
||||
'online_order_no' => $row->online_order_no ?? $row->bill_order_no ?? null,
|
||||
'shop_name' => $row->shop_name ?? null,
|
||||
'shop_code' => $row->shop_code ?? null,
|
||||
'product_code' => $row->product_code ?? $row->bill_product_code ?? null,
|
||||
'style_code' => $row->style_code ?? null,
|
||||
'shop_product_code' => $row->shop_product_code ?? $row->bill_shop_product_code ?? null,
|
||||
'shop_style_code' => $row->shop_style_code ?? $row->bill_shop_style_code ?? null,
|
||||
'amount' => round((float) ($row->amount ?? 0), 2),
|
||||
];
|
||||
}
|
||||
|
||||
private function dateTime(mixed $value): string
|
||||
{
|
||||
return $value === null || $value === '' ? '' : date('Y-m-d H:i:s', strtotime((string) $value));
|
||||
}
|
||||
|
||||
private function contains(string $value): string
|
||||
{
|
||||
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%';
|
||||
}
|
||||
}
|
||||
@ -13,17 +13,26 @@ use Throwable;
|
||||
/** 快递应付运费对账的共享业务镜像只读访问。 */
|
||||
class CourierFreightReconciliationMirrorRepository
|
||||
{
|
||||
/** 保留旧契约,供现有结算同步和调用方识别。 */
|
||||
public const DATASET = 'jiansun.dbo.fys_express_courier_settlement';
|
||||
public const DETAILS_DATASET = 'jiansun.dbo.jst_order_express_details';
|
||||
public const EXTENSION_DATASET = 'jiansun.dbo.Jst_SendData_View_extension';
|
||||
public const OUTBOUND_DATASET = 'jiansun.dbo.jst_salesOutboundInfo';
|
||||
|
||||
/** Only columns present in js_express_courier_settlement may be exposed. */
|
||||
/** @var list<string> */
|
||||
private const REQUIRED_DATASETS = [
|
||||
self::DETAILS_DATASET,
|
||||
self::EXTENSION_DATASET,
|
||||
self::OUTBOUND_DATASET,
|
||||
];
|
||||
|
||||
/** 仅暴露附件中有演示值、且已经核对来源的字段。 */
|
||||
private const COLUMNS = [
|
||||
'Id', 'month', 'status', 'courier_company_name', 'expresscode', 'expressname',
|
||||
'replenish_courier', 'opening_balance', 'courier_provided_amount',
|
||||
'reconciliation_difference', 'confirmed_amount', 'waybill', 'advance_payment',
|
||||
'payable_amount', 'actual_payment', 'reconciliation_person', 'file_status',
|
||||
'create_time', 'modify_time', 'is_delete', 'taskid', 'guid', 'creator',
|
||||
'compensation_amount', 'other_replenishment', 'corporate_payment_amount',
|
||||
'personal_payment_amount',
|
||||
'detail_id', 'waybill', 'product_id', 'send_time', 'transaction_order_no',
|
||||
'province', 'city', 'district', 'barcode', 'express_name', 'express_name_2', 'quantity',
|
||||
'product_name', 'platform', 'shop_name', 'warehouse', 'volume', 'supplier',
|
||||
'length', 'width', 'height', 'freight', 'reconciliation_weight',
|
||||
'reconciliation_volume', 'month', 'status',
|
||||
];
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
@ -39,10 +48,17 @@ class CourierFreightReconciliationMirrorRepository
|
||||
{
|
||||
try {
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
$dataset = $this->datasets->get(self::DATASET);
|
||||
if (! $schema->hasTable('business_mirror_table_states')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $schema->hasTable('business_mirror_table_states')
|
||||
&& $schema->hasTable($dataset->targetTable());
|
||||
foreach (self::REQUIRED_DATASETS as $key) {
|
||||
if (! $schema->hasTable($this->datasets->get($key)->targetTable())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
@ -51,7 +67,13 @@ class CourierFreightReconciliationMirrorRepository
|
||||
public function ready(): bool
|
||||
{
|
||||
try {
|
||||
return $this->mirror->ready($this->datasets->get(self::DATASET));
|
||||
foreach (self::REQUIRED_DATASETS as $key) {
|
||||
if (! $this->mirror->ready($this->datasets->get($key))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
@ -61,7 +83,36 @@ class CourierFreightReconciliationMirrorRepository
|
||||
public function state(): ?array
|
||||
{
|
||||
try {
|
||||
return $this->mirror->state($this->datasets->get(self::DATASET));
|
||||
$states = [];
|
||||
foreach (self::REQUIRED_DATASETS as $key) {
|
||||
$state = $this->mirror->state($this->datasets->get($key));
|
||||
if (is_array($state)) {
|
||||
$states[$key] = $state;
|
||||
}
|
||||
}
|
||||
if ($states === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$first = $states[self::DETAILS_DATASET] ?? reset($states);
|
||||
$initial = array_values(array_filter(array_map(
|
||||
static fn (array $state): ?string => $state['initial_completed_at'] ?? null,
|
||||
$states,
|
||||
)));
|
||||
$succeeded = array_values(array_filter(array_map(
|
||||
static fn (array $state): ?string => $state['last_succeeded_at'] ?? null,
|
||||
$states,
|
||||
)));
|
||||
$statuses = array_map(static fn (array $state): string => (string) ($state['status'] ?? 'pending'), $states);
|
||||
|
||||
$first['initial_completed_at'] = count($initial) === count(self::REQUIRED_DATASETS) ? min($initial) : null;
|
||||
$first['last_succeeded_at'] = count($succeeded) === count(self::REQUIRED_DATASETS) ? min($succeeded) : null;
|
||||
$first['status'] = in_array('failed', $statuses, true)
|
||||
? 'failed'
|
||||
: (in_array('running', $statuses, true) ? 'running' : (count(array_filter($statuses, static fn (string $value): bool => $value === 'succeeded')) === count(self::REQUIRED_DATASETS) ? 'succeeded' : 'pending'));
|
||||
$first['datasets'] = $states;
|
||||
|
||||
return $first;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
@ -107,79 +158,113 @@ class CourierFreightReconciliationMirrorRepository
|
||||
*/
|
||||
public function paginate(array $filters, int $page, int $perPage): array
|
||||
{
|
||||
$dataset = $this->datasets->get(self::DATASET);
|
||||
if (! $this->mirror->ready($dataset)) {
|
||||
if (! $this->ready()) {
|
||||
throw new RuntimeException('courier_freight_reconciliation_mirror_not_ready');
|
||||
}
|
||||
|
||||
$page = max($page, 1);
|
||||
$perPage = min(max($perPage, 1), 100);
|
||||
$query = $this->filteredQuery($dataset->targetTable(), $filters);
|
||||
$total = (clone $query)->count();
|
||||
$query = $this->filteredQuery($filters);
|
||||
// Both joins select at most one latest row, so this count is also the
|
||||
// paginator total. Avoid traversing the entire joined history twice.
|
||||
$summary = (clone $query)->selectRaw(
|
||||
'COUNT(*) AS aggregate_count, '
|
||||
. 'COALESCE(SUM(payable_amount), 0) AS payable_amount, '
|
||||
. 'COALESCE(SUM(actual_payment), 0) AS actual_payment, '
|
||||
. 'COALESCE(SUM(confirmed_amount), 0) AS confirmed_amount, '
|
||||
. 'COALESCE(SUM(reconciliation_difference), 0) AS reconciliation_difference'
|
||||
'COUNT(d.id) AS aggregate_count, COALESCE(SUM(o.freight), 0) AS freight',
|
||||
)->first();
|
||||
$rows = (clone $query)->select(self::COLUMNS)
|
||||
->orderByDesc('Id')
|
||||
$total = (int) ($summary->aggregate_count ?? 0);
|
||||
$rows = (clone $query)->select($this->selectColumns())
|
||||
->orderByDesc('d.id')
|
||||
->forPage($page, $perPage)
|
||||
->get()
|
||||
->map(fn (object $row): array => $this->mapRow($row))
|
||||
->all();
|
||||
$state = $this->mirror->state($dataset);
|
||||
$state = $this->state();
|
||||
$freight = (float) ($summary->freight ?? 0);
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
'total' => $total,
|
||||
'summary' => [
|
||||
'count' => (int) ($summary->aggregate_count ?? 0),
|
||||
'payable_amount' => (float) ($summary->payable_amount ?? 0),
|
||||
'actual_payment' => (float) ($summary->actual_payment ?? 0),
|
||||
'confirmed_amount' => (float) ($summary->confirmed_amount ?? 0),
|
||||
'reconciliation_difference' => (float) ($summary->reconciliation_difference ?? 0),
|
||||
'freight' => $freight,
|
||||
'payable_amount' => $freight,
|
||||
'actual_payment' => 0.0,
|
||||
'confirmed_amount' => 0.0,
|
||||
'reconciliation_difference' => 0.0,
|
||||
],
|
||||
'state' => $state,
|
||||
'sync' => $this->syncStatus($state),
|
||||
];
|
||||
}
|
||||
|
||||
private function filteredQuery(string $table, array $filters): Builder
|
||||
private function filteredQuery(array $filters): Builder
|
||||
{
|
||||
$query = $this->connection->table($table)
|
||||
->where(static function (Builder $query): void {
|
||||
$query->whereNull('is_delete')->orWhere('is_delete', '=', 0);
|
||||
});
|
||||
$this->containsFilter($query, 'waybill', $filters['express_no'] ?? '');
|
||||
$this->containsFilter($query, 'expressname', $filters['express_name'] ?? '');
|
||||
$query = $this->detailQuery()->whereNotNull('d.logistic_id')->where('d.logistic_id', '!=', '');
|
||||
$this->containsFilter($query, 'd.logistic_id', $filters['express_no'] ?? '');
|
||||
|
||||
if (($filters['month'] ?? '') !== '') {
|
||||
$query->where('month', '=', $filters['month']);
|
||||
if (($filters['express_name'] ?? '') !== '') {
|
||||
$needle = $this->contains((string) $filters['express_name']);
|
||||
$query->where(function (Builder $name) use ($needle): void {
|
||||
$name->whereRaw("d.logistic_company_name LIKE ? ESCAPE '!'", [$needle])
|
||||
->orWhereRaw("x.expressName LIKE ? ESCAPE '!'", [$needle]);
|
||||
});
|
||||
}
|
||||
if (($filters['month'] ?? '') !== '' && preg_match('/^\d{4}-\d{2}$/', (string) $filters['month']) === 1) {
|
||||
$from = (string) $filters['month'] . '-01';
|
||||
$query->where('d.send_time', '>=', $from . ' 00:00:00')
|
||||
->where('d.send_time', '<', date('Y-m-d 00:00:00', strtotime($from . ' +1 month')));
|
||||
}
|
||||
if (($filters['status'] ?? '') !== '') {
|
||||
$query->where('status', '=', $filters['status']);
|
||||
$query->where('d.logistic_status_name', '=', $filters['status']);
|
||||
}
|
||||
if (($filters['modified_from'] ?? '') !== '') {
|
||||
$query->where('modify_time', '>=', $filters['modified_from'] . ' 00:00:00');
|
||||
$query->where('d.updatedAt', '>=', $filters['modified_from'] . ' 00:00:00');
|
||||
}
|
||||
if (($filters['modified_to'] ?? '') !== '') {
|
||||
$query->where('modify_time', '<', date('Y-m-d 00:00:00', strtotime($filters['modified_to'] . ' +1 day')));
|
||||
$query->where('d.updatedAt', '<', date('Y-m-d 00:00:00', strtotime($filters['modified_to'] . ' +1 day')));
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function containsFilter(Builder $query, string $column, mixed $value): void
|
||||
private function detailQuery(): Builder
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return;
|
||||
}
|
||||
$details = $this->datasets->get(self::DETAILS_DATASET)->targetTable();
|
||||
$extension = $this->datasets->get(self::EXTENSION_DATASET)->targetTable();
|
||||
$outbound = $this->datasets->get(self::OUTBOUND_DATASET)->targetTable();
|
||||
|
||||
$escaped = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value);
|
||||
$query->whereRaw($column . " LIKE ? ESCAPE '!'", ['%' . $escaped . '%']);
|
||||
return $this->connection->table($details . ' as d')
|
||||
->leftJoinSub($this->latestBy($extension, 'so_id', 'Id'), 'x', 'x.so_id', '=', 'd.so_id')
|
||||
->leftJoinSub($this->latestBy($outbound, 'io_id', 'Id'), 'o', 'o.io_id', '=', 'x.io_id');
|
||||
}
|
||||
|
||||
/** 固定取关联键下 Id 最大的一行,避免一对多重复页面行和运费汇总。 */
|
||||
private function latestBy(string $table, string $relationColumn, string $idColumn): Builder
|
||||
{
|
||||
return $this->connection->table($table . ' as candidate')
|
||||
->whereNotExists(function (Builder $query) use ($table, $relationColumn, $idColumn): void {
|
||||
$query->selectRaw('1')
|
||||
->from($table . ' as newer')
|
||||
->whereColumn('newer.' . $relationColumn, '=', 'candidate.' . $relationColumn)
|
||||
->whereColumn('newer.' . $idColumn, '>', 'candidate.' . $idColumn);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function selectColumns(): array
|
||||
{
|
||||
return [
|
||||
'd.id as detail_id', 'd.logistic_id as waybill', 'x.shop_i_id as product_id',
|
||||
'd.send_time', $this->connection->raw('COALESCE(x.so_id, d.so_id) as transaction_order_no'), 'x.receiver_state as province',
|
||||
'x.receiver_city as city', 'x.receiver_district as district',
|
||||
'x.sku_ids as barcode',
|
||||
'd.logistic_company_name as express_name', 'x.expressName as express_name_2',
|
||||
'x.qty as quantity', 'x.sku_name as product_name', 'd.platform_name as platform',
|
||||
$this->connection->raw('COALESCE(x.shop_name, d.shop_name) as shop_name'),
|
||||
$this->connection->raw('COALESCE(x.wareHoursName, d.warehouse_name) as warehouse'), 'x.volume as volume',
|
||||
'x.supplier_name as supplier', 'x.l as length', 'x.w as width', 'x.h as height',
|
||||
'o.freight as freight', 'd.f_weight as reconciliation_weight',
|
||||
'x.volume as reconciliation_volume', 'd.logistic_status_name as status',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
@ -189,10 +274,29 @@ class CourierFreightReconciliationMirrorRepository
|
||||
foreach (self::COLUMNS as $column) {
|
||||
$mapped[$column] = $row->{$column} ?? null;
|
||||
}
|
||||
foreach (['opening_balance', 'courier_provided_amount', 'reconciliation_difference', 'confirmed_amount', 'advance_payment', 'payable_amount', 'actual_payment', 'compensation_amount', 'other_replenishment', 'corporate_payment_amount', 'personal_payment_amount'] as $amount) {
|
||||
$mapped[$amount] = $mapped[$amount] === null ? null : (float) $mapped[$amount];
|
||||
|
||||
$sendTime = $mapped['send_time'];
|
||||
$mapped['month'] = is_string($sendTime) ? substr($sendTime, 0, 7) : null;
|
||||
foreach (['length', 'width', 'height', 'volume', 'freight', 'reconciliation_weight', 'reconciliation_volume'] as $number) {
|
||||
$mapped[$number] = $mapped[$number] === null || $mapped[$number] === '' ? null : (float) $mapped[$number];
|
||||
}
|
||||
if ($mapped['quantity'] !== null && $mapped['quantity'] !== '') {
|
||||
$mapped['quantity'] = (int) $mapped['quantity'];
|
||||
}
|
||||
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
private function containsFilter(Builder $query, string $column, mixed $value): void
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value !== '') {
|
||||
$query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains($value)]);
|
||||
}
|
||||
}
|
||||
|
||||
private function contains(string $value): string
|
||||
{
|
||||
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%';
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ use RuntimeException;
|
||||
class GenericBusinessMirrorRepository
|
||||
{
|
||||
private readonly ConnectionInterface $connection;
|
||||
private ?bool $supportsExactCount = null;
|
||||
|
||||
public function __construct(DatabaseManager $database)
|
||||
{
|
||||
@ -93,7 +94,14 @@ class GenericBusinessMirrorRepository
|
||||
$query = $this->connection->table('business_mirror_table_states')
|
||||
->where('dataset_key', $dataset->key());
|
||||
|
||||
if ($query->exists()) {
|
||||
$existing = $query->first();
|
||||
if ($this->supportsExactCount()) {
|
||||
$values['mirror_row_count_is_exact'] = $mode === 'incremental'
|
||||
&& $existing !== null && $existing->initial_completed_at !== null
|
||||
&& ctype_digit((string) ($existing->mirror_row_count ?? ''))
|
||||
&& ($existing->status === 'succeeded' || (bool) ($existing->mirror_row_count_is_exact ?? false));
|
||||
}
|
||||
if ($existing !== null) {
|
||||
$query->update($values);
|
||||
} else {
|
||||
$this->connection->table('business_mirror_table_states')->insert(
|
||||
@ -137,6 +145,16 @@ class GenericBusinessMirrorRepository
|
||||
$this->connection->transaction(function () use (
|
||||
$dataset, $columnNames, $mapped, $cursor, $cumulativeRows, $now
|
||||
): void {
|
||||
$exactCount = null;
|
||||
if ($this->supportsExactCount()) {
|
||||
$state = $this->connection->table('business_mirror_table_states')
|
||||
->where('dataset_key', $dataset->key())->lockForUpdate()->first();
|
||||
if ($state && $state->run_mode === 'incremental' && $state->mirror_row_count_is_exact) {
|
||||
$keys = array_values(array_unique(array_column($mapped, '_mirror_key')));
|
||||
$existing = $this->connection->table($dataset->targetTable())->whereIn('_mirror_key', $keys)->count();
|
||||
$exactCount = (int) $state->mirror_row_count + count($keys) - $existing;
|
||||
}
|
||||
}
|
||||
$updates = [...$columnNames, '_mirror_generation', '_mirror_synced_at'];
|
||||
$chunkSize = max(1, intdiv(50000, max(count($updates) + 1, 1)));
|
||||
|
||||
@ -150,7 +168,7 @@ class GenericBusinessMirrorRepository
|
||||
'cursor' => json_encode($cursor, JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION),
|
||||
'last_upserted_rows' => $cumulativeRows,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
] + ($exactCount === null ? [] : ['mirror_row_count' => $exactCount]));
|
||||
});
|
||||
}
|
||||
|
||||
@ -181,7 +199,12 @@ class GenericBusinessMirrorRepository
|
||||
->delete();
|
||||
}
|
||||
|
||||
$rowCount = $this->connection->table($dataset->targetTable())->count();
|
||||
$countState = $this->supportsExactCount()
|
||||
? $this->connection->table('business_mirror_table_states')->where('dataset_key', $dataset->key())->lockForUpdate()->first()
|
||||
: null;
|
||||
$rowCount = $mode === 'incremental' && $countState && $countState->mirror_row_count_is_exact
|
||||
? (int) $countState->mirror_row_count
|
||||
: $this->connection->table($dataset->targetTable())->count();
|
||||
$values = [
|
||||
'status' => 'succeeded',
|
||||
'cursor' => null,
|
||||
@ -197,6 +220,7 @@ class GenericBusinessMirrorRepository
|
||||
$values['initial_completed_at'] = $now;
|
||||
$values['last_full_succeeded_at'] = $now;
|
||||
}
|
||||
if ($this->supportsExactCount()) $values['mirror_row_count_is_exact'] = true;
|
||||
|
||||
$this->connection->table('business_mirror_table_states')
|
||||
->where('dataset_key', $dataset->key())
|
||||
@ -215,6 +239,12 @@ class GenericBusinessMirrorRepository
|
||||
]);
|
||||
}
|
||||
|
||||
private function supportsExactCount(): bool
|
||||
{
|
||||
return $this->supportsExactCount ??= Schema::connection($this->connection->getName())
|
||||
->hasColumn('business_mirror_table_states', 'mirror_row_count_is_exact');
|
||||
}
|
||||
|
||||
private function decode(mixed $value): ?array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
|
||||
@ -21,6 +21,12 @@ class ProductCatalogMirrorRepository
|
||||
|
||||
public const PURCHASE_SOURCE_LABEL = '1688代发单价';
|
||||
|
||||
public const WORK_PLATFORM_SOURCE_LABEL = '工作平台价格';
|
||||
|
||||
public const WORK_PLATFORM_PRICE_DATASET_KEY = 'jiansun.dbo.pm_priceAdjustment';
|
||||
|
||||
private const WORK_PLATFORM_PRICE_TABLE = 'js_pm_price_adjustment';
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(
|
||||
@ -84,7 +90,14 @@ class ProductCatalogMirrorRepository
|
||||
return ['code' => $code, 'lag_minutes' => $lagMinutes];
|
||||
}
|
||||
|
||||
/** @return array{data:list<object>,total:int,purchase_price_sync_status:array{code:string,initialized:bool,last_succeeded_at:?string}} */
|
||||
/**
|
||||
* @return array{
|
||||
* data:list<object>,
|
||||
* total:int,
|
||||
* purchase_price_sync_status:array{code:string,initialized:bool,last_succeeded_at:?string},
|
||||
* work_platform_price_sync_status:array{code:string,initialized:bool,last_succeeded_at:?string}
|
||||
* }
|
||||
*/
|
||||
public function paginate(array $filters, int $page, int $perPage): array
|
||||
{
|
||||
if (! $this->ready()) {
|
||||
@ -103,11 +116,14 @@ class ProductCatalogMirrorRepository
|
||||
|
||||
$purchasePriceSyncStatus = $this->purchasePriceSyncStatus();
|
||||
$this->attachPurchasePrices($rows, $purchasePriceSyncStatus['initialized']);
|
||||
$workPlatformPriceSyncStatus = $this->workPlatformPriceSyncStatus();
|
||||
$this->attachWorkPlatformPrices($rows, $workPlatformPriceSyncStatus['initialized']);
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
'total' => $total,
|
||||
'purchase_price_sync_status' => $purchasePriceSyncStatus,
|
||||
'work_platform_price_sync_status' => $workPlatformPriceSyncStatus,
|
||||
];
|
||||
}
|
||||
|
||||
@ -123,6 +139,7 @@ class ProductCatalogMirrorRepository
|
||||
|
||||
$batchSize = min(max($batchSize, 1), 5000);
|
||||
$purchasePriceSyncStatus = $this->purchasePriceSyncStatus();
|
||||
$workPlatformPriceSyncStatus = $this->workPlatformPriceSyncStatus();
|
||||
$lastAutoid = null;
|
||||
|
||||
while (true) {
|
||||
@ -140,6 +157,7 @@ class ProductCatalogMirrorRepository
|
||||
}
|
||||
|
||||
$this->attachPurchasePrices($rows, $purchasePriceSyncStatus['initialized']);
|
||||
$this->attachWorkPlatformPrices($rows, $workPlatformPriceSyncStatus['initialized']);
|
||||
foreach ($rows as $row) {
|
||||
yield $row;
|
||||
}
|
||||
@ -213,6 +231,100 @@ class ProductCatalogMirrorRepository
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作平台采购价是普通商品资料的补充价格来源,不覆盖商品自身采购价。
|
||||
* 只取已生效记录;同一 SKU 按生效时间和稳定键取最新一条。
|
||||
*
|
||||
* @param list<object> $rows
|
||||
*/
|
||||
private function attachWorkPlatformPrices(array $rows, bool $initialized): void
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
$row->work_platform_price = null;
|
||||
$row->work_platform_price_effect_time = null;
|
||||
}
|
||||
|
||||
if ($rows === [] || ! $initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
$skuIds = collect($rows)
|
||||
->pluck('sku_id')
|
||||
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->map(static fn (string $value): string => trim($value))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
if ($skuIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$candidates = $this->connection->table($this->workPlatformPriceTable())
|
||||
->whereIn('skuCode', $skuIds)
|
||||
->whereNotNull('purchasePrice')
|
||||
->where(static function ($query) use ($now): void {
|
||||
$query->whereNull('effectTime')->orWhere('effectTime', '<=', $now);
|
||||
})
|
||||
->orderBy('skuCode')
|
||||
->orderByDesc('effectTime')
|
||||
->orderByDesc('Id')
|
||||
->get(['skuCode', 'purchasePrice', 'effectTime'])
|
||||
->unique('skuCode')
|
||||
->keyBy('skuCode');
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$price = $candidates->get(trim((string) ($row->sku_id ?? '')));
|
||||
if ($price === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row->work_platform_price = $price->purchasePrice;
|
||||
$row->work_platform_price_effect_time = $price->effectTime;
|
||||
$row->data_sources[] = self::WORK_PLATFORM_SOURCE_LABEL;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{code:string,initialized:bool,last_succeeded_at:?string} */
|
||||
public function workPlatformPriceSyncStatus(): array
|
||||
{
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
if (! $schema->hasTable('business_mirror_table_states')
|
||||
|| ! $schema->hasTable($this->workPlatformPriceTable())) {
|
||||
return ['code' => 'unavailable', 'initialized' => false, 'last_succeeded_at' => null];
|
||||
}
|
||||
|
||||
$state = $this->connection->table('business_mirror_table_states')
|
||||
->where('dataset_key', self::WORK_PLATFORM_PRICE_DATASET_KEY)
|
||||
->first();
|
||||
|
||||
if ($state === null || $state->initial_completed_at === null) {
|
||||
$code = match ((string) ($state->status ?? 'pending')) {
|
||||
'running' => 'initializing',
|
||||
'failed' => 'initial_failed',
|
||||
default => 'pending',
|
||||
};
|
||||
|
||||
return ['code' => $code, 'initialized' => false, 'last_succeeded_at' => null];
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 'ready',
|
||||
'initialized' => true,
|
||||
'last_succeeded_at' => ($state->last_succeeded_at ?? null) === null
|
||||
? null
|
||||
: (string) $state->last_succeeded_at,
|
||||
];
|
||||
}
|
||||
|
||||
private function workPlatformPriceTable(): string
|
||||
{
|
||||
return (string) config(
|
||||
'upstream_mirrors.work_platform_prices.target_table',
|
||||
self::WORK_PLATFORM_PRICE_TABLE,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $filters */
|
||||
private function filteredQuery(array $filters): Builder
|
||||
{
|
||||
|
||||
@ -20,6 +20,26 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
|
||||
/** @return array{data:array<int,array<string,mixed>>,total:int,current_page:int,last_page:int,per_page:int,summary:array{income:float,expense:float,balance:float,count:int},sync:array<string,mixed>} */
|
||||
public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array
|
||||
{
|
||||
return $this->paginateChannel($filters, $sort, $direction, $page, $perPage, null, 'taobao');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one upstream platform channel from the shared original-bill mirror.
|
||||
* The mirror is shared by several account pages; callers must provide a
|
||||
* fixed source channel rather than accepting arbitrary SQL/filter input.
|
||||
*
|
||||
* @return array{data:array<int,array<string,mixed>>,total:int,current_page:int,last_page:int,per_page:int,summary:array{income:float,expense:float,balance:float,count:int},sync:array<string,mixed>}
|
||||
*/
|
||||
public function paginateChannel(
|
||||
array $filters,
|
||||
string $sort,
|
||||
string $direction,
|
||||
int $page,
|
||||
int $perPage,
|
||||
?string $channel,
|
||||
string $mapping = 'taobao',
|
||||
): array
|
||||
{
|
||||
$dataset = $this->datasets->get(self::DATASET);
|
||||
if (! $this->mirror->ready($dataset)
|
||||
@ -39,16 +59,18 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
][$sort] ?? 'b.fee_time';
|
||||
|
||||
$query = $this->billQuery();
|
||||
$this->applyChannel($query, $channel);
|
||||
$this->applyFilters($query, $filters);
|
||||
$state = $this->mirror->state($dataset) ?? [];
|
||||
// The synchronizer persists an exact COUNT in the same transaction that
|
||||
// marks a run successful. Never substitute a database row estimate.
|
||||
// Completed runs persist an exact COUNT. New incremental batches keep
|
||||
// that count exact in their upsert transaction, including inserts.
|
||||
$hasFilters = collect($filters)->contains(static fn (mixed $value): bool => $value !== null && trim((string) $value) !== '');
|
||||
$recordedCount = $state['mirror_row_count'] ?? null;
|
||||
$useRecordedCount = ! $hasFilters && ($state['status'] ?? null) === 'succeeded'
|
||||
$useRecordedCount = $channel === null && ! $hasFilters
|
||||
&& (($state['status'] ?? null) === 'succeeded' || (bool) ($state['mirror_row_count_is_exact'] ?? false))
|
||||
&& $recordedCount !== null && ctype_digit((string) $recordedCount);
|
||||
$total = $useRecordedCount ? (int) $recordedCount : (clone $query)->count();
|
||||
$summary = $this->summary($query, $filters);
|
||||
$summary = $this->summary($query, $filters, $channel);
|
||||
|
||||
$billRows = $query
|
||||
->select('b.*')
|
||||
@ -59,7 +81,7 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
->all();
|
||||
$shopNames = $this->shopNames($billRows);
|
||||
$rows = collect($billRows)
|
||||
->map(fn (object $row): array => $this->mapRow($row, $shopNames[(string) $row->shop_id] ?? null))
|
||||
->map(fn (object $row): array => $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping))
|
||||
->all();
|
||||
$income = $summary['income'];
|
||||
$expense = $summary['expense'];
|
||||
@ -84,7 +106,19 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
}
|
||||
|
||||
/** @return iterable<int,array<string,mixed>> */
|
||||
public function exportRows(array $filters): iterable
|
||||
public function exportRows(array $filters, string $sort = 'occurred_at', string $direction = 'asc'): iterable
|
||||
{
|
||||
return $this->exportChannelRows($filters, $sort, $direction, null, 'taobao');
|
||||
}
|
||||
|
||||
/** @return iterable<int,array<string,mixed>> */
|
||||
public function exportChannelRows(
|
||||
array $filters,
|
||||
string $sort = 'occurred_at',
|
||||
string $direction = 'asc',
|
||||
?string $channel = null,
|
||||
string $mapping = 'taobao',
|
||||
): iterable
|
||||
{
|
||||
$dataset = $this->datasets->get(self::DATASET);
|
||||
if (! $this->mirror->ready($dataset)
|
||||
@ -93,12 +127,60 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
}
|
||||
|
||||
$query = $this->billQuery();
|
||||
$this->applyChannel($query, $channel);
|
||||
$this->applyFilters($query, $filters);
|
||||
$shopNames = $this->allShopNames();
|
||||
$direction = $direction === 'desc' ? 'desc' : 'asc';
|
||||
$sortColumn = [
|
||||
'record_code' => 'b.Id',
|
||||
'occurred_at' => 'b.fee_time',
|
||||
'income' => 'b.amount',
|
||||
'expense' => 'b.amount',
|
||||
'balance' => 'b.amount',
|
||||
][$sort] ?? 'b.fee_time';
|
||||
|
||||
return $query->select('b.*')
|
||||
->orderBy('b.fee_time')->orderBy('b.Id')->cursor()
|
||||
->map(fn (object $row): array => $this->mapRow($row, $shopNames[(string) $row->shop_id] ?? null));
|
||||
return $this->exportBatches($query, $sortColumn, $direction, $shopNames, $mapping);
|
||||
}
|
||||
|
||||
/** Read bounded pages so a full export does not sort/buffer millions of rows at once. */
|
||||
private function exportBatches(Builder $query, string $sortColumn, string $direction, array $shopNames, string $mapping): iterable
|
||||
{
|
||||
$operator = $direction === 'asc' ? '>' : '<';
|
||||
$sortField = substr($sortColumn, 2);
|
||||
$last = null;
|
||||
$maximumId = $this->billQuery()->max('b.Id');
|
||||
if ($maximumId === null) return;
|
||||
|
||||
do {
|
||||
$batch = (clone $query)->where('b.Id', '<=', $maximumId);
|
||||
if ($last !== null) {
|
||||
if ($sortColumn === 'b.Id') {
|
||||
$batch->where('b.Id', $operator, $last->Id);
|
||||
} else {
|
||||
$value = $last->{$sortField};
|
||||
$batch->where(function (Builder $after) use ($sortColumn, $direction, $operator, $last, $value): void {
|
||||
if ($value === null) {
|
||||
$after->where(function (Builder $ties) use ($sortColumn, $operator, $last): void {
|
||||
$ties->whereNull($sortColumn)->where('b.Id', $operator, $last->Id);
|
||||
});
|
||||
if ($direction === 'asc') $after->orWhereNotNull($sortColumn);
|
||||
} else {
|
||||
$after->where($sortColumn, $operator, $value)
|
||||
->orWhere(function (Builder $ties) use ($sortColumn, $operator, $last, $value): void {
|
||||
$ties->where($sortColumn, $value)->where('b.Id', $operator, $last->Id);
|
||||
});
|
||||
if ($direction === 'desc') $after->orWhereNull($sortColumn);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
$rows = $batch->select('b.*')->orderBy($sortColumn, $direction)
|
||||
->orderBy('b.Id', $direction)->limit(1000)->get();
|
||||
foreach ($rows as $row) {
|
||||
yield $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping);
|
||||
}
|
||||
$last = $rows->last();
|
||||
} while ($rows->count() === 1000);
|
||||
}
|
||||
|
||||
private function billQuery(): Builder
|
||||
@ -107,9 +189,9 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
}
|
||||
|
||||
/** @return array{income:float,expense:float} */
|
||||
private function summary(Builder $billQuery, array $filters): array
|
||||
private function summary(Builder $billQuery, array $filters, ?string $channel = null): array
|
||||
{
|
||||
if ($this->dailySummarySupports($filters)) {
|
||||
if ($channel === null && $this->dailySummarySupports($filters)) {
|
||||
$summary = $this->dailySummary($filters);
|
||||
if ($summary !== null) {
|
||||
return $summary;
|
||||
@ -210,6 +292,13 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
}
|
||||
}
|
||||
|
||||
private function applyChannel(Builder $query, ?string $channel): void
|
||||
{
|
||||
if ($channel !== null && $channel !== '') {
|
||||
$query->where('b.channel', $channel);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<int,object> $rows @return array<string,string> */
|
||||
private function shopNames(array $rows): array
|
||||
{
|
||||
@ -269,6 +358,54 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function mapRowFor(object $row, ?string $shopName, string $mapping): array
|
||||
{
|
||||
if ($mapping !== 'douyin') {
|
||||
return $this->mapRow($row, $shopName);
|
||||
}
|
||||
|
||||
$amount = abs((float) ($row->amount ?? 0));
|
||||
$income = (string) ($row->inout_type ?? '') === '1' ? $amount : 0.0;
|
||||
$expense = (string) ($row->inout_type ?? '') === '-1' ? $amount : 0.0;
|
||||
$direction = $this->directionName($row->inout_type);
|
||||
|
||||
return [
|
||||
'id' => (string) $row->Id,
|
||||
'record_code' => (string) $row->Id,
|
||||
'store_name' => $shopName,
|
||||
'finance_remark' => null,
|
||||
'order_no' => $row->so_id ?: $row->p_auto_id,
|
||||
'amount' => $amount,
|
||||
'custom_remark' => null,
|
||||
'bill_item' => $row->fee_name,
|
||||
'occurred_at' => $this->dateTime($row->fee_time),
|
||||
'income' => $income,
|
||||
'expense' => $expense,
|
||||
'balance' => $income - $expense,
|
||||
'remark' => null,
|
||||
'modifier' => '聚水潭',
|
||||
'created_at' => $this->dateTime($row->createTime),
|
||||
'updated_at' => $this->dateTime($row->updateTime),
|
||||
'payload' => [
|
||||
'merchant_name' => $shopName,
|
||||
'move_serial_no' => $row->auto_id ?: $row->primary_key,
|
||||
'order_no_2' => $row->p_auto_id ?: $row->so_id,
|
||||
'online_order_no' => $row->so_id,
|
||||
'sub_order_no' => $row->shop_i_id,
|
||||
'goods_id' => $row->sku_id ?: $row->shop_sku_id,
|
||||
'account_type' => $direction,
|
||||
'move_summary' => $row->fee_name,
|
||||
'order_placed_at' => $this->dateTime($row->createTime),
|
||||
'business_type' => $row->combine_type,
|
||||
'settle_account' => $row->channel,
|
||||
'move_scene' => '头条放心购',
|
||||
'talent_id' => null,
|
||||
'talent_name' => null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function directionCode(string $value): string
|
||||
{
|
||||
return match ($value) {
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
/** 店铺收款账单·抖音账户(头条放心购)只读共享业务镜像。 */
|
||||
class StoreReceivingDouyinBillMirrorRepository
|
||||
{
|
||||
public const SOURCE_CHANNEL = 'TouTiaoFXGZJ';
|
||||
|
||||
public function __construct(
|
||||
private readonly StoreReceivingAlipayBillMirrorRepository $source,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array
|
||||
{
|
||||
return $this->source->paginateChannel(
|
||||
$filters,
|
||||
$sort,
|
||||
$direction,
|
||||
$page,
|
||||
$perPage,
|
||||
self::SOURCE_CHANNEL,
|
||||
'douyin',
|
||||
);
|
||||
}
|
||||
|
||||
/** @return iterable<int,array<string,mixed>> */
|
||||
public function exportRows(array $filters, string $sort = 'occurred_at', string $direction = 'asc'): iterable
|
||||
{
|
||||
return $this->source->exportChannelRows(
|
||||
$filters,
|
||||
$sort,
|
||||
$direction,
|
||||
self::SOURCE_CHANNEL,
|
||||
'douyin',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Database\Query\Expression;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
@ -35,7 +36,10 @@ class BusinessMirrorSchemaManager
|
||||
}
|
||||
|
||||
foreach ($this->validatedIndexes($dataset, $columns) as $position => $index) {
|
||||
$table->index($index, $this->configuredIndexName($dataset->targetTable(), $index, $position));
|
||||
$table->index(
|
||||
$this->indexColumns($index),
|
||||
$this->configuredIndexName($dataset->targetTable(), $index, $position),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -62,13 +66,20 @@ class BusinessMirrorSchemaManager
|
||||
->all();
|
||||
$missingIndexes = array_values(array_filter(
|
||||
$configuredIndexes,
|
||||
static fn (array $index): bool => ! in_array(array_map('strtolower', $index), $existingIndexes, true),
|
||||
fn (array $index): bool => ! in_array(
|
||||
array_map(fn (string $column): string => strtolower($this->indexBaseColumn($column)), $index),
|
||||
$existingIndexes,
|
||||
true,
|
||||
),
|
||||
));
|
||||
|
||||
if ($missingIndexes !== []) {
|
||||
$schema->table($dataset->targetTable(), function (Blueprint $table) use ($dataset, $missingIndexes): void {
|
||||
foreach ($missingIndexes as $position => $index) {
|
||||
$table->index($index, $this->configuredIndexName($dataset->targetTable(), $index, $position));
|
||||
$table->index(
|
||||
$this->indexColumns($index),
|
||||
$this->configuredIndexName($dataset->targetTable(), $index, $position),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -148,7 +159,7 @@ class BusinessMirrorSchemaManager
|
||||
// may opt a bounded legacy identifier into VARCHAR; every other
|
||||
// wide string remains TEXT and is stored off-page.
|
||||
'char', 'nchar', 'varchar', 'nvarchar', 'character', 'character varying' => $length > 0
|
||||
&& ($length <= 64 || ($length <= 191 && $this->isConfiguredIndexColumn($dataset, $name)))
|
||||
&& ($length <= 64 || ($length <= 255 && $this->isConfiguredIndexColumn($dataset, $name)))
|
||||
? $table->string($name, $length)
|
||||
: $table->longText($name),
|
||||
default => $table->longText($name),
|
||||
@ -169,7 +180,7 @@ class BusinessMirrorSchemaManager
|
||||
|
||||
return ! in_array($type, ['text', 'ntext', 'xml', 'image', 'binary', 'varbinary', 'bytea', 'json', 'jsonb', 'array', 'user-defined'], true)
|
||||
&& (! in_array($type, ['char', 'nchar', 'varchar', 'nvarchar', 'character', 'character varying'], true)
|
||||
|| ($length > 0 && ($length <= 64 || ($length <= 191 && $dataset !== null && $this->isConfiguredIndexColumn($dataset, $name)))));
|
||||
|| ($length > 0 && ($length <= 64 || ($length <= 255 && $dataset !== null && $this->isConfiguredIndexColumn($dataset, $name)))));
|
||||
}
|
||||
|
||||
return false;
|
||||
@ -190,8 +201,16 @@ class BusinessMirrorSchemaManager
|
||||
|
||||
foreach ($dataset->indexDefinitions() as $index) {
|
||||
foreach ($index as $column) {
|
||||
if (! in_array(strtolower($column), $columnNames, true)
|
||||
|| ! $this->isIndexable($columns, $column, $dataset)) {
|
||||
$base = $this->indexBaseColumn($column);
|
||||
if (! in_array(strtolower($base), $columnNames, true)) {
|
||||
throw new RuntimeException('business_mirror_index_column_unsupported:' . $dataset->key());
|
||||
}
|
||||
|
||||
if ($base !== $column) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->isIndexable($columns, $base, $dataset)) {
|
||||
throw new RuntimeException('business_mirror_index_column_unsupported:' . $dataset->key());
|
||||
}
|
||||
}
|
||||
@ -213,6 +232,23 @@ class BusinessMirrorSchemaManager
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @param list<string> $index @return list<string|Expression> */
|
||||
private function indexColumns(array $index): array
|
||||
{
|
||||
return array_map(function (string $column): string|Expression {
|
||||
if (preg_match('/^([A-Za-z_][A-Za-z0-9_]*)\((\d+)\)$/', $column, $matched) !== 1) {
|
||||
return $column;
|
||||
}
|
||||
|
||||
return DB::raw(sprintf('`%s`(%d)', $matched[1], (int) $matched[2]));
|
||||
}, $index);
|
||||
}
|
||||
|
||||
private function indexBaseColumn(string $column): string
|
||||
{
|
||||
return preg_replace('/\(\d+\)$/', '', $column) ?? $column;
|
||||
}
|
||||
|
||||
private function configuredIndexName(string $table, array $columns, int $position): string
|
||||
{
|
||||
$signature = implode('_', $columns) . '_' . $position;
|
||||
|
||||
@ -12,15 +12,36 @@ class BillOffsetPageData
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
/** 账单冲减复用原始数据的订单售后费用明细,不重新推断科目。 */
|
||||
public function page(): array
|
||||
{
|
||||
$filters = $this->orderData->afterSalesFilters();
|
||||
$page = max((int) request('page', 1), 1);
|
||||
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
|
||||
return ['view' => 'business.pages.order-data.bill-offset'] + $this->data();
|
||||
}
|
||||
|
||||
/** @param array<string,mixed>|null $input */
|
||||
public function data(?array $input = null, ?int $page = null, ?int $limit = null): array
|
||||
{
|
||||
$filters = $this->orderData->afterSalesFilters($input);
|
||||
|
||||
return $this->dataForNormalizedFilters(
|
||||
$filters,
|
||||
$page ?? (int) request('page', 1),
|
||||
$limit ?? (int) setting('default.list_limit', 25),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuse an already normalized filter set for subsequent export pages.
|
||||
*
|
||||
* @param array{from:string,to:string,order_no:string,shop_name:string,sku_id:string} $filters
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function dataForNormalizedFilters(array $filters, int $page, int $limit): array
|
||||
{
|
||||
$page = max($page, 1);
|
||||
$limit = min(max($limit, 1), 100);
|
||||
$rows = $this->emptyPaginator($page, $limit);
|
||||
$status = null;
|
||||
$pageAmount = null;
|
||||
$error = null;
|
||||
|
||||
try {
|
||||
@ -36,39 +57,26 @@ class BillOffsetPageData
|
||||
);
|
||||
$rows->hasMorePagesWhen((bool) ($result['has_more'] ?? false));
|
||||
$status = data_get($result, 'status.X-Caiwu-Data-Status');
|
||||
$pageAmount = $this->pageAmount($rows->items());
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$error = '订单售后费用数据暂时无法读取,请稍后重试。';
|
||||
}
|
||||
|
||||
$amounts = Collection::make($rows->items())->pluck('account_500112');
|
||||
// 500112 未接入时保留为空;本页有任何未知金额时不生成误导性合计。
|
||||
$amount = $amounts->isNotEmpty() && $amounts->every(static fn ($value): bool => is_numeric($value))
|
||||
? round((float) $amounts->sum(), 2)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'view' => 'business.pages.order-data.bill-offset',
|
||||
'billOffsetRows' => $rows,
|
||||
'billOffsetFilters' => $filters,
|
||||
'billOffsetRows' => $rows,
|
||||
'billOffsetSummary' => ['count' => $rows->count(), 'amount' => $amount],
|
||||
'billOffsetStatus' => $status,
|
||||
'billOffsetPageAmount' => $pageAmount,
|
||||
'billOffsetError' => $error,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int,mixed> $items */
|
||||
private function pageAmount(array $items): ?float
|
||||
{
|
||||
$amount = null;
|
||||
|
||||
foreach ($items as $item) {
|
||||
$value = data_get($item, 'account_500112');
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = ($amount ?? 0.0) + (float) $value;
|
||||
}
|
||||
|
||||
return $amount === null ? null : round($amount, 2);
|
||||
}
|
||||
|
||||
private function emptyPaginator(int $page, int $limit): Paginator
|
||||
{
|
||||
return new Paginator([], $limit, $page, [
|
||||
|
||||
@ -23,10 +23,12 @@ class OrderDataPageData
|
||||
) {
|
||||
}
|
||||
|
||||
public function afterSales(): array
|
||||
public function afterSales(?array $filters = null, ?int $page = null, ?int $limit = null, bool $includeWorkOrders = true): array
|
||||
{
|
||||
$filters = $this->afterSalesFilters();
|
||||
[$page, $limit] = $this->pageSettings();
|
||||
$filters = $this->afterSalesFilters($filters);
|
||||
[$requestPage, $requestLimit] = $this->pageSettings();
|
||||
$page = max($page ?? $requestPage, 1);
|
||||
$limit = min(max($limit ?? $requestLimit, 1), 100);
|
||||
|
||||
try {
|
||||
$result = $this->afterSalesRows($filters, $page, $limit);
|
||||
@ -41,7 +43,7 @@ class OrderDataPageData
|
||||
'orderDataFilters' => $filters,
|
||||
'orderDataStatus' => $result['status'] ?? [],
|
||||
'orderDataError' => null,
|
||||
'afterSalesWorkOrders' => $this->afterSalesWorkOrders($filters),
|
||||
'afterSalesWorkOrders' => $includeWorkOrders ? $this->afterSalesWorkOrders($filters) : $this->emptyAfterSalesWorkOrders(),
|
||||
];
|
||||
} catch (ServiceUnavailableHttpException $exception) {
|
||||
return $this->failed($filters, $page, $limit, $exception->getMessage());
|
||||
|
||||
@ -42,10 +42,9 @@
|
||||
import TableColumnSettings from './TableColumnSettings.vue';
|
||||
const SORTABLE = ['occurred_at','income','expense','balance','record_code'];
|
||||
export default {
|
||||
name:'AccountBills', components:{ TableColumnSettings }, props:{ apiBase:{type:String,required:true}, billType:{type:String,required:true} },
|
||||
name:'AccountBills', components:{ TableColumnSettings }, props:{ apiBase:{type:String,required:true}, billType:{type:String,required:true}, userKey:{type:[String,Number],required:true} },
|
||||
data(){return{loading:false,loadError:false,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:{store_name:'',bill_item:'',start:'',end:'',keyword:''},sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible'};},
|
||||
computed:{
|
||||
userKey(){const mount=document.querySelector('.business-blank-page');return mount&&mount.dataset.tablePreferencesUser?mount.dataset.tablePreferencesUser:'anonymous';},
|
||||
visibleColumns(){return this.configuredColumns.filter(column=>column.visible);},
|
||||
visibleGroups(){return this.visibleColumns.reduce((groups,column)=>{if(!column.group)return groups;const last=groups[groups.length-1];if(last&&last.label===column.group)last.span+=1;else groups.push({label:column.group,span:1});return groups;},[]);},
|
||||
stats(){return[{label:'收入合计',value:this.money(this.summary.income),note:`${this.summary.count} 笔交易`,className:'is-income'},{label:'支出合计',value:this.money(this.summary.expense),note:'按当前筛选',className:'is-expense'},{label:'期末余额(最新)',value:this.money(this.summary.balance),note:'最新一条记录',className:''},{label:'交易笔数',value:this.summary.count,note:'当前筛选结果',className:''}];},
|
||||
@ -60,7 +59,7 @@ export default {
|
||||
getValue(row,field){if(!field)return'';if(field.indexOf('payload.')===0){const payload=row.payload||{};const value=payload[field.slice(8)];return value===null||value===undefined?'':value;}const value=row[field];return value===null||value===undefined?'':value;},rawValue(row,column){return this.getValue(row,column.field);},
|
||||
displayValue(row,column){const value=this.rawValue(row,column);if(value==='')return'—';if(column.kind==='money_in')return Number(value)>0?`+${this.money(value)}`:'—';if(column.kind==='money_out')return Number(value)>0?`-${this.money(value)}`:'—';if(['balance','money'].indexOf(column.kind)!==-1)return this.money(value);return value;},cellClass(column){return{'is-number':this.isNumber(column.kind),'is-id':column.kind==='num','is-income':column.kind==='money_in','is-expense':column.kind==='money_out'};},isNumber(kind){return['money','money_in','money_out','balance','num'].indexOf(kind)!==-1;},money(value){return`¥${Number(value||0).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})}`;},
|
||||
exportRows(){if(this.exportMode==='page-visible'){this.downloadCurrentPage();return;}const params=new URLSearchParams(this.filters);params.set('export','1');params.set('sort',this.sort.key);params.set('dir',this.sort.dir);if(this.exportMode==='filtered-visible')this.visibleColumns.forEach(column=>params.append('columns[]',column.field));window.location.assign(`${this.apiBase}?${params.toString()}`);},
|
||||
downloadCurrentPage(){const csvRows=[this.visibleColumns.map(column=>column.label),...this.rows.map(row=>this.visibleColumns.map(column=>this.rawValue(row,column)))];const csv=csvRows.map(values=>values.map(this.csvCell).join(',')).join('\r\n');const link=document.createElement('a');link.href=URL.createObjectURL(new Blob(['\ufeff',csv],{type:'text/csv;charset=utf-8'}));link.download=`${this.billType}_account_bills_page_${this.currentPage}.csv`;link.click();URL.revokeObjectURL(link.href);},csvCell(value){const text=String(value===null||value===undefined?'':value);const safe=/^[=+\-@]/.test(text)?`'${text}`:text;return`"${safe.replace(/"/g,'""')}"`;}
|
||||
downloadCurrentPage(){const csvRows=[this.visibleColumns.map(column=>column.label),...this.rows.map(row=>this.visibleColumns.map(column=>this.rawValue(row,column)))];const csv=csvRows.map(values=>values.map((value, index) => this.csvCell(value, this.visibleColumns[index])).join(',')).join('\r\n');const link=document.createElement('a');link.href=URL.createObjectURL(new Blob(['\ufeff',csv],{type:'text/csv;charset=utf-8'}));link.download=`${this.billType}_account_bills_page_${this.currentPage}.csv`;link.click();URL.revokeObjectURL(link.href);},csvCell(value,column){const text=String(value===null||value===undefined?'':value);const safe=column.kind==='num'&&/^(?:0\d|\d{12,}$)/.test(text)?`\t${text}`:(/^[=+\-@]/.test(text)?`'${text}`:text);return`"${safe.replace(/"/g,'""')}"`;}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -44,6 +44,8 @@
|
||||
<span>{{ column.label }}</span>
|
||||
</label>
|
||||
<span v-if="column.fixed" class="table-column-settings__fixed">固定</span>
|
||||
<button type="button" class="table-column-settings__move" :disabled="index === 0" :aria-label="`上移${column.label}`" @click="move(index, -1)"><span class="material-icons-outlined" aria-hidden="true">arrow_upward</span></button>
|
||||
<button type="button" class="table-column-settings__move" :disabled="index === workingColumns.length - 1" :aria-label="`下移${column.label}`" @click="move(index, 1)"><span class="material-icons-outlined" aria-hidden="true">arrow_downward</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -93,10 +95,12 @@ export default {
|
||||
|
||||
mounted() {
|
||||
document.addEventListener('click', this.onDocumentClick);
|
||||
document.addEventListener('keydown', this.onKeydown);
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('click', this.onDocumentClick);
|
||||
document.removeEventListener('keydown', this.onKeydown);
|
||||
},
|
||||
|
||||
methods: {
|
||||
@ -118,7 +122,7 @@ export default {
|
||||
saved = null;
|
||||
}
|
||||
|
||||
const savedMap = new Map(Array.isArray(saved) ? saved.map((item) => [item.key, item]) : []);
|
||||
const savedMap = new Map(Array.isArray(saved) ? saved.filter((item) => item && typeof item.key === 'string').map((item) => [item.key, item]) : []);
|
||||
const ordered = defaults
|
||||
.map((column, defaultIndex) => ({
|
||||
...column,
|
||||
@ -153,7 +157,7 @@ export default {
|
||||
this.emitChange();
|
||||
},
|
||||
reset() {
|
||||
window.localStorage.removeItem(this.storageKey);
|
||||
try { window.localStorage.removeItem(this.storageKey); } catch (_) {}
|
||||
this.workingColumns = this.normalizedDefaults();
|
||||
this.emitChange(false);
|
||||
},
|
||||
@ -164,7 +168,7 @@ export default {
|
||||
visible: column.visible,
|
||||
order,
|
||||
}));
|
||||
window.localStorage.setItem(this.storageKey, JSON.stringify(value));
|
||||
try { window.localStorage.setItem(this.storageKey, JSON.stringify(value)); } catch (_) {}
|
||||
}
|
||||
this.$emit('change', this.workingColumns.map((column) => ({ ...column })));
|
||||
},
|
||||
@ -173,6 +177,21 @@ export default {
|
||||
this.open = false;
|
||||
}
|
||||
},
|
||||
move(index, direction) {
|
||||
const destination = index + direction;
|
||||
if (destination < 0 || destination >= this.workingColumns.length) return;
|
||||
const next = this.workingColumns.slice();
|
||||
const [column] = next.splice(index, 1);
|
||||
next.splice(destination, 0, column);
|
||||
this.workingColumns = next;
|
||||
this.emitChange();
|
||||
},
|
||||
onKeydown(event) {
|
||||
if (event.key === 'Escape' && this.open) {
|
||||
this.open = false;
|
||||
this.$refs.root.querySelector('button').focus();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@ -221,4 +240,8 @@ export default {
|
||||
.table-column-settings__item input { accent-color: #856632; }
|
||||
.table-column-settings__drag { color: #856632; cursor: grab; font-size: 1.125rem; }
|
||||
.table-column-settings__fixed { color: #595959; font-size: 0.71875rem; }
|
||||
.table-column-settings__move { border: 0; background: transparent; color: #856632; padding: .25rem; cursor: pointer; }
|
||||
.table-column-settings__move .material-icons-outlined { font-size: 1rem; }
|
||||
.table-column-settings__move:disabled { opacity: .35; cursor: default; }
|
||||
.table-column-settings__move:focus-visible { outline: 2px solid #856632; outline-offset: 1px; }
|
||||
</style>
|
||||
|
||||
@ -80,10 +80,9 @@ const SORTABLE = ['occurred_at', 'income', 'expense', 'balance', 'record_code'];
|
||||
|
||||
export default {
|
||||
name: 'TaobaoAlipayBills', components: { TableColumnSettings },
|
||||
props: { apiBase: { type: String, required: true }, exportBase: { type: String, required: true } },
|
||||
props: { apiBase: { type: String, required: true }, exportBase: { type: String, required: true }, userKey: { type: [String, Number], required: true } },
|
||||
data() { return { loading: false, error: '', rows: [], configuredColumns: [], summary: { income: 0, expense: 0, balance: 0, count: 0 }, total: 0, currentPage: 1, lastPage: 1, perPage: 20, filters: { store_name: '', alipay_account: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }, sort: { key: 'occurred_at', dir: 'desc' }, exportMode: 'filtered-visible' }; },
|
||||
computed: {
|
||||
userKey() { const mount = document.querySelector('.business-blank-page'); return mount && mount.dataset.tablePreferencesUser ? mount.dataset.tablePreferencesUser : 'anonymous'; },
|
||||
defaultColumns() { return COLUMNS; }, visibleColumns() { return this.configuredColumns.filter((column) => column.visible); },
|
||||
visibleGroups() { return this.visibleColumns.reduce((groups, column) => { const last = groups[groups.length - 1]; if (last && last.label === column.group) last.span += 1; else groups.push({ label: column.group, span: 1 }); return groups; }, []); },
|
||||
stats() { return [{ label: '收入合计', value: this.money(this.summary.income), note: `${this.summary.count} 笔交易`, className: 'is-income' }, { label: '支出合计', value: this.money(this.summary.expense), note: '按当前筛选', className: 'is-expense' }, { label: '收支净额', value: this.money(this.summary.balance), note: '收入合计 - 支出合计', className: '' }, { label: '交易笔数', value: this.summary.count, note: '当前筛选结果', className: '' }]; },
|
||||
@ -99,8 +98,8 @@ export default {
|
||||
displayValue(row, column) { const value = this.rawValue(row, column); if (value === '') return '—'; if (column.kind === 'money_in') return Number(value) > 0 ? `+${this.money(value)}` : '—'; if (column.kind === 'money_out') return Number(value) > 0 ? `-${this.money(value)}` : '—'; if (column.kind === 'money') return this.money(value); return value; },
|
||||
cellClass(column) { return { 'is-number': this.isNumber(column.kind), 'is-id': column.kind === 'num', 'is-income': column.kind === 'money_in', 'is-expense': column.kind === 'money_out' }; }, isNumber(kind) { return ['num', 'money', 'money_in', 'money_out'].indexOf(kind) !== -1; }, money(value) { return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; },
|
||||
exportRows() { if (this.exportMode === 'page-visible') { this.downloadCurrentPage(); return; } const params = new URLSearchParams(this.filters); params.set('sort', this.sort.key); params.set('dir', this.sort.dir); if (this.exportMode === 'filtered-visible') this.visibleColumns.forEach((column) => params.append('columns[]', column.field)); window.location.assign(`${this.exportBase}?${params.toString()}`); },
|
||||
downloadCurrentPage() { const csvRows = [this.visibleColumns.map((column) => column.label), ...this.rows.map((row) => this.visibleColumns.map((column) => this.rawValue(row, column)))]; const csv = csvRows.map((values) => values.map(this.csvCell).join(',')).join('\r\n'); const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8' })); link.download = `taobao_alipay_bills_page_${this.currentPage}.csv`; link.click(); URL.revokeObjectURL(link.href); },
|
||||
csvCell(value) { const text = String(value === null || value === undefined ? '' : value); const safe = /^[=+\-@]/.test(text) ? `'${text}` : text; return `"${safe.replace(/"/g, '""')}"`; },
|
||||
downloadCurrentPage() { const csvRows = [this.visibleColumns.map((column) => column.label), ...this.rows.map((row) => this.visibleColumns.map((column) => this.rawValue(row, column)))]; const csv = csvRows.map((values) => values.map((value, index) => this.csvCell(value, this.visibleColumns[index])).join(',')).join('\r\n'); const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8' })); link.download = `taobao_alipay_bills_page_${this.currentPage}.csv`; link.click(); URL.revokeObjectURL(link.href); },
|
||||
csvCell(value, column) { const text = String(value === null || value === undefined ? '' : value); const safe = column.kind === 'num' && /^(?:0\d|\d{12,}$)/.test(text) ? `\t${text}` : (/^[=+\-@]/.test(text) ? `'${text}` : text); return `"${safe.replace(/"/g, '""')}"`; },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
16
resources/assets/js/views/business/pages.js
vendored
16
resources/assets/js/views/business/pages.js
vendored
@ -5,8 +5,9 @@
|
||||
*
|
||||
* 店铺收款账户账单(store-receiving-bills)下各 tab:
|
||||
* - taobao-alipay → 独立实现 TaobaoAlipayBills(经营分析副本共享镜像)
|
||||
* - taobao-wechat / pinduoduo / jd-wallet / jd-transactions / douyin / dewu
|
||||
* → 通用实现 AccountBills(表 ec_account_bills,按 bill_type 区分,列配置由后端下发)
|
||||
* - taobao-wechat / pinduoduo / jd-wallet / jd-transactions / dewu
|
||||
* → 通用实现 AccountBills(表 ec_account_bills,按 bill_type 区分)
|
||||
* - douyin → AccountBills 展示共享原始账单镜像中的头条放心购渠道
|
||||
*/
|
||||
|
||||
require('./../../bootstrap');
|
||||
@ -61,6 +62,9 @@ const mount = document.querySelector('.business-blank-page');
|
||||
|
||||
if (mount) {
|
||||
const initialContext = pageContext();
|
||||
// Vue replaces the original mount node; keep the identity across local tab switches.
|
||||
const preferencesUser = mount.dataset.tablePreferencesUser || 'anonymous';
|
||||
const titleSuffix = document.title.includes(' - ') ? document.title.slice(document.title.lastIndexOf(' - ')) : '';
|
||||
|
||||
function isStoreReceivingContext(context) {
|
||||
return context.page === 'store-receiving-bills'
|
||||
@ -80,6 +84,12 @@ if (mount) {
|
||||
link.removeAttribute('aria-current');
|
||||
}
|
||||
});
|
||||
const label = document.querySelector('.business-tab-link[aria-current="page"] span:last-child')?.textContent.trim();
|
||||
const title = document.querySelector('h1 [data-title-truncate]');
|
||||
if (label && title) {
|
||||
title.textContent = label;
|
||||
document.title = label + titleSuffix;
|
||||
}
|
||||
}
|
||||
|
||||
/* eslint-disable no-new */
|
||||
@ -95,7 +105,7 @@ if (mount) {
|
||||
if (resolved) {
|
||||
return h(resolved.Component, {
|
||||
key: this.context.tab,
|
||||
props: resolved.props,
|
||||
props: { ...resolved.props, userKey: preferencesUser },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -6,9 +6,28 @@
|
||||
$ready = (bool) ($courierSurchargeReady ?? false);
|
||||
$typeUsage = $courierSurchargeTypeUsage ?? [];
|
||||
$statusClass = static fn (string $status): string => $status === '已启用' ? 'is-active' : 'is-inactive';
|
||||
$columns = [
|
||||
['key' => 'id', 'label' => '规则编号', 'fixed' => true, 'exportable' => true],
|
||||
['key' => 'courier', 'label' => '快递名称', 'exportable' => true],
|
||||
['key' => 'scope', 'label' => '快递归属', 'exportable' => true],
|
||||
['key' => 'reconciler', 'label' => '对账人', 'exportable' => true],
|
||||
['key' => 'type', 'label' => '附加费类型', 'exportable' => true],
|
||||
['key' => 'mode', 'label' => '结算方式', 'exportable' => true],
|
||||
['key' => 'standard', 'label' => '计价标准', 'exportable' => true],
|
||||
['key' => 'condition', 'label' => '生效条件', 'exportable' => true],
|
||||
['key' => 'effective_at', 'label' => '生效日期', 'exportable' => true],
|
||||
['key' => 'status', 'label' => '状态', 'exportable' => true],
|
||||
['key' => 'remark', 'label' => '备注', 'exportable' => true],
|
||||
['key' => 'actions', 'label' => '操作', 'fixed' => true, 'exportable' => false],
|
||||
];
|
||||
$columnStorageKey = sprintf(
|
||||
'caiwu:table-columns:%d:%d:courier-surcharge:quotes',
|
||||
(int) auth()->id(),
|
||||
(int) company_id(),
|
||||
);
|
||||
@endphp
|
||||
|
||||
<section class="surcharge-maintenance" aria-label="附加费报价维护" data-surcharge-maintenance>
|
||||
<section class="surcharge-maintenance" aria-label="附加费报价维护" data-surcharge-maintenance data-column-storage-key="{{ $columnStorageKey }}">
|
||||
<style>
|
||||
.surcharge-maintenance { --sm-ink:var(--vip-content-ink,#171717); --sm-muted:var(--vip-content-muted,#595959); --sm-accent:var(--vip-content-accent-dark,#856632); --sm-soft:var(--vip-content-accent-soft,#F7F0DE); --sm-border:var(--vip-border,#D8D2C7); display:grid; gap:1rem; color:var(--sm-ink); font-family:JetBrains Mono,"Microsoft YaHei","PingFang SC",monospace; font-size:.8125rem; }
|
||||
.surcharge-maintenance *, .surcharge-maintenance *::before, .surcharge-maintenance *::after { box-sizing:border-box; }
|
||||
@ -21,19 +40,21 @@
|
||||
.sm-summary__icon { display:grid; flex:none; width:2.4rem; height:2.4rem; place-items:center; border-radius:.6rem; background:var(--sm-soft); color:var(--sm-accent); }
|
||||
.sm-summary__title { font-size:.95rem; font-weight:700; } .sm-summary__subtitle { margin-top:.2rem; color:var(--sm-muted); font-size:.72rem; }
|
||||
.sm-metric { border-left:1px solid var(--sm-border); } .sm-metric__label { color:var(--sm-muted); font-size:.72rem; } .sm-metric__value { margin-top:.25rem; font-size:1.2rem; font-weight:700; font-variant-numeric:tabular-nums; } .sm-metric__value.is-active { color:#53793e; } .sm-metric__value.is-inactive { color:#935f07; }
|
||||
.sm-card { overflow:hidden; border:1px solid var(--sm-border); border-radius:.75rem; background:#fff; }
|
||||
.sm-card__head { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:.85rem 1.15rem; border-bottom:1px solid var(--sm-border); }
|
||||
.sm-card { overflow:visible; border:1px solid var(--sm-border); border-radius:.75rem; background:#fff; }
|
||||
.sm-card__head { display:flex; align-items:flex-start; justify-content:space-between; flex-wrap:wrap; gap:.75rem 1rem; padding:.85rem 1.15rem; border-bottom:1px solid var(--sm-border); }
|
||||
.sm-card__title { display:flex; align-items:center; gap:.45rem; font-size:.9rem; font-weight:700; } .sm-card__title .material-icons-outlined { color:var(--sm-accent); font-size:1.1rem; } .sm-card__hint { color:var(--sm-muted); font-size:.72rem; }
|
||||
.sm-toolbar { display:grid; grid-template-columns:minmax(12rem,1.35fr) repeat(6,minmax(8rem,1fr)) auto; gap:.7rem; align-items:end; padding:1rem 1.15rem; background:#fffdf8; }
|
||||
.sm-toolbar { display:grid; grid-template-columns:repeat(4,minmax(10rem,1fr)); gap:.7rem; align-items:end; padding:1rem 1.15rem; background:#fffdf8; }
|
||||
.sm-field { display:grid; gap:.3rem; min-width:0; } .sm-field label { color:var(--sm-muted); font-size:.72rem; font-weight:600; } .sm-field input,.sm-field select,.sm-form input,.sm-form select,.sm-form textarea { width:100%; min-width:0; padding:0 .6rem; border:1px solid var(--sm-border); border-radius:.45rem; background:#fff; color:var(--sm-ink); font:inherit; } .sm-field input,.sm-field select { height:2.2rem; }
|
||||
.sm-field input:focus,.sm-field select:focus,.sm-form input:focus,.sm-form select:focus,.sm-form textarea:focus { border-color:var(--sm-accent); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.18); }
|
||||
.sm-actions { display:flex; gap:.45rem; } .sm-button { display:inline-flex; align-items:center; justify-content:center; gap:.35rem; height:2.2rem; padding:0 .75rem; border:1px solid var(--sm-border); border-radius:.45rem; background:#fff; color:var(--sm-ink); font:inherit; font-weight:600; text-decoration:none; white-space:nowrap; cursor:pointer; } .sm-button .material-icons-outlined { font-size:1rem; } .sm-button--primary { border-color:var(--sm-accent); background:var(--sm-accent); color:#fff; } .sm-button--primary:hover { background:#6a5129; }
|
||||
.sm-table-wrap { overflow:auto; } .sm-table { width:100%; min-width:82rem; border-collapse:separate; border-spacing:0; font-size:.75rem; } .sm-table th,.sm-table td { padding:.62rem .7rem; border-right:1px solid var(--sm-border); border-bottom:1px solid var(--sm-border); text-align:left; vertical-align:top; white-space:nowrap; } .sm-table th { position:sticky; top:0; z-index:1; background:var(--sm-soft); font-weight:700; } .sm-table tr:hover td { background:#fcfaf4; } .sm-table .wrap { max-width:18rem; white-space:normal; line-height:1.5; } .sm-table .actions { display:flex; gap:.35rem; } .sm-table .actions button { height:1.8rem; padding:0 .5rem; font-size:.7rem; }
|
||||
.sm-actions { display:flex; flex-wrap:wrap; gap:.45rem; } .sm-button { display:inline-flex; align-items:center; justify-content:center; gap:.35rem; height:2.2rem; padding:0 .75rem; border:1px solid var(--sm-border); border-radius:.45rem; background:#fff; color:var(--sm-ink); font:inherit; font-weight:600; text-decoration:none; white-space:nowrap; cursor:pointer; } .sm-button .material-icons-outlined { font-size:1rem; } .sm-button--primary { border-color:var(--sm-accent); background:var(--sm-accent); color:#fff; } .sm-button--primary:hover { background:#6a5129; } .sm-button:disabled { cursor:not-allowed; opacity:.55; }
|
||||
.sm-table-wrap { max-height:min(62vh,42rem); overflow:auto; overscroll-behavior:contain; scrollbar-gutter:stable both-edges; } .sm-table { width:max-content; min-width:100%; border-collapse:separate; border-spacing:0; font-size:.75rem; } .sm-table th,.sm-table td { min-width:7.5rem; padding:.62rem .7rem; border-right:1px solid var(--sm-border); border-bottom:1px solid var(--sm-border); text-align:left; vertical-align:top; white-space:nowrap; } .sm-table th { position:sticky; top:0; z-index:2; background:var(--sm-soft); font-weight:700; } .sm-table [data-column-key="id"] { position:sticky; left:0; z-index:3; background:#fff; } .sm-table th[data-column-key="id"] { z-index:5; background:var(--sm-soft); } .sm-table [data-column-key="actions"] { position:sticky; right:0; z-index:3; background:#fff; box-shadow:-1px 0 0 var(--sm-border); } .sm-table th[data-column-key="actions"] { z-index:5; background:var(--sm-soft); } .sm-table tr:hover [data-column-key="id"],.sm-table tr:hover [data-column-key="actions"] { background:#fcfaf4; } .sm-table th[data-fixed="true"] { cursor:default; } .sm-table th[data-column-header]:not([data-fixed="true"]) { cursor:grab; } .sm-table th[data-column-header]:not([data-fixed="true"]):active { cursor:grabbing; } .sm-table th.is-dragging { opacity:.55; } .sm-table th.is-drop-target { box-shadow:inset 3px 0 0 var(--sm-accent); } .sm-table th .sm-table__header { display:inline-flex; align-items:center; gap:.35rem; } .sm-table th .sm-table__drag { color:var(--sm-muted); font-size:1rem; } .sm-table [data-column-key].is-column-hidden { display:none; } .sm-table tr:hover td { background:#fcfaf4; } .sm-table .wrap { max-width:18rem; white-space:normal; line-height:1.5; } .sm-table .actions { display:flex; gap:.35rem; } .sm-table .actions button { height:1.8rem; padding:0 .5rem; font-size:.7rem; }
|
||||
.sm-table-meta { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.6rem 1rem; padding:.65rem 1.15rem; border-bottom:1px solid var(--sm-border); background:#fffdf8; color:var(--sm-muted); font-size:.72rem; }
|
||||
.sm-column-picker { position:relative; } .sm-column-picker__panel { position:absolute; right:0; z-index:20; display:grid; width:min(22rem,calc(100vw - 2rem)); gap:.65rem; margin-top:.35rem; padding:.8rem; border:1px solid var(--sm-border); border-radius:.55rem; background:#fff; box-shadow:0 4px 8px rgba(23,23,23,.1); } .sm-column-picker__panel[hidden] { display:none; } .sm-column-picker__intro { display:grid; gap:.2rem; color:var(--sm-muted); font-size:.7rem; line-height:1.45; } .sm-column-picker__intro strong { color:var(--sm-ink); font-size:.78rem; } .sm-visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } .sm-column-picker__list { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.35rem; max-height:15rem; overflow:auto; } .sm-column-picker__option { display:flex; align-items:center; gap:.35rem; min-width:0; padding:.35rem .4rem; border:1px solid transparent; border-radius:.35rem; color:var(--sm-ink); font-size:.7rem; cursor:grab; } .sm-column-picker__option:hover,.sm-column-picker__option.is-drop-target { border-color:var(--sm-border); background:#fffdf8; } .sm-column-picker__option.is-fixed { cursor:default; } .sm-column-picker__option.is-dragging { opacity:.55; } .sm-column-picker__option input { flex:none; accent-color:var(--sm-accent); } .sm-column-picker__option .material-icons-outlined { color:var(--sm-muted); font-size:.95rem; } .sm-column-picker__foot { display:flex; justify-content:flex-end; padding-top:.35rem; border-top:1px solid var(--sm-border); }
|
||||
.sm-badge { display:inline-flex; align-items:center; padding:.15rem .5rem; border-radius:999px; font-size:.7rem; font-weight:700; } .sm-badge.is-active { background:#edf5e8; color:#53793e; } .sm-badge.is-inactive { background:#fff4df; color:#935f07; }
|
||||
.sm-empty { padding:3rem 1rem; text-align:center; color:var(--sm-muted); } .sm-rule-note { padding:1rem 1.15rem; color:var(--sm-muted); font-size:.75rem; line-height:1.65; background:#fffdf8; }
|
||||
.sm-type-manager { display:grid; gap:1rem; padding:1.15rem; } .sm-type-create { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.7rem; align-items:end; padding-bottom:1rem; border-bottom:1px solid var(--sm-border); } .sm-type-list { display:grid; gap:.55rem; } .sm-type-list__head { display:grid; gap:.2rem; color:var(--sm-muted); font-size:.72rem; line-height:1.5; } .sm-type-list__head strong { color:var(--sm-ink); font-size:.8rem; } .sm-type-row { display:flex; align-items:center; justify-content:space-between; gap:.75rem; min-height:2.6rem; padding:.5rem .65rem; border:1px solid var(--sm-border); border-radius:.45rem; background:#fffdf8; } .sm-type-row > div { display:flex; align-items:center; flex-wrap:wrap; gap:.45rem; min-width:0; } .sm-type-row small { color:var(--sm-muted); font-size:.68rem; } .sm-type-row form { flex:none; }
|
||||
.sm-modal { position:fixed; inset:0; z-index:70; display:none; align-items:flex-start; justify-content:center; padding:4rem 1rem; overflow-y:auto; background:rgba(23,23,23,.42); } .sm-modal.is-open { display:flex; } .sm-modal__panel { width:min(42rem,100%); overflow:hidden; border:1px solid var(--sm-border); border-radius:.75rem; background:#fff; box-shadow:0 1rem 3rem rgba(23,23,23,.2); } .sm-modal__head { display:flex; align-items:center; justify-content:space-between; padding:1rem 1.15rem; border-bottom:1px solid var(--sm-border); } .sm-modal__head h2 { margin:0; font-size:1rem; } .sm-modal__close { border:0; background:transparent; color:var(--sm-muted); cursor:pointer; } .sm-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.8rem; padding:1.15rem; } .sm-form__field { display:grid; gap:.3rem; } .sm-form__field--full { grid-column:span 2; } .sm-form__field label,.sm-form__field legend { color:var(--sm-muted); font-size:.72rem; font-weight:600; } .sm-form__types { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.45rem; margin:0; padding:0; border:0; } .sm-type-option { display:flex; align-items:center; gap:.35rem; min-height:2.1rem; padding:.35rem .45rem; border:1px solid var(--sm-border); border-radius:.4rem; background:#fffdf8; color:var(--sm-ink); font-size:.72rem; cursor:pointer; } .sm-type-option:has(input:checked) { border-color:var(--sm-accent); background:var(--sm-soft); color:var(--sm-accent); font-weight:700; } .sm-type-option input { width:1rem; height:1rem; accent-color:var(--sm-accent); } .sm-form input,.sm-form select { height:2.25rem; } .sm-form textarea { min-height:4rem; padding:.55rem .6rem; resize:vertical; } .sm-form__foot { grid-column:span 2; display:flex; justify-content:flex-end; gap:.5rem; margin-top:.2rem; }
|
||||
@media (max-width:1100px) { .sm-summary { grid-template-columns:repeat(3,1fr); } .sm-summary__intro { grid-column:span 3; border-bottom:1px solid var(--sm-border); } .sm-toolbar { grid-template-columns:repeat(3,minmax(0,1fr)); } .sm-actions { grid-column:span 3; } }
|
||||
@media (max-width:1100px) { .sm-summary { grid-template-columns:repeat(3,1fr); } .sm-summary__intro { grid-column:span 3; border-bottom:1px solid var(--sm-border); } .sm-toolbar { grid-template-columns:repeat(3,minmax(0,1fr)); } .sm-toolbar > .sm-actions { grid-column:span 3; } }
|
||||
@media (max-width:620px) { .sm-summary { grid-template-columns:repeat(2,1fr); } .sm-summary__intro { grid-column:span 2; } .sm-metric { border-left:0; border-top:1px solid var(--sm-border); } .sm-toolbar,.sm-form,.sm-type-create { grid-template-columns:minmax(0,1fr); } .sm-form__types { grid-template-columns:repeat(2,minmax(0,1fr)); } .sm-actions,.sm-form__field--full,.sm-form__foot { grid-column:auto; } .sm-actions .sm-button { flex:1; } }
|
||||
</style>
|
||||
|
||||
@ -62,15 +83,31 @@
|
||||
</div>
|
||||
|
||||
<div class="sm-card">
|
||||
<div class="sm-card__head"><div class="sm-card__title"><span class="material-icons-outlined" aria-hidden="true">rule</span>报价规则列表</div><div class="sm-card__hint">规则按生效时间应用到后续结算页面</div></div>
|
||||
<div class="sm-table-wrap"><table class="sm-table"><thead><tr><th>规则编号</th><th>快递名称</th><th>快递归属</th><th>对账人</th><th>附加费类型</th><th>结算方式</th><th>计价标准</th><th>生效条件</th><th>生效日期</th><th>状态</th><th>备注</th><th>操作</th></tr></thead><tbody>
|
||||
<div class="sm-card__head"><div class="sm-card__title"><span class="material-icons-outlined" aria-hidden="true">rule</span>报价规则列表</div><div class="sm-actions"><div class="sm-column-picker" data-column-picker><button class="sm-button" type="button" aria-expanded="false" aria-controls="surcharge-column-options" data-column-toggle><span class="material-icons-outlined" aria-hidden="true">view_column</span>列设置</button><div class="sm-column-picker__panel" id="surcharge-column-options" role="group" aria-label="报价规则列设置" title="勾选字段可控制显示,拖动字段可调整顺序" hidden><div class="sm-column-picker__intro"><strong>列设置</strong><span class="sm-visually-hidden">勾选字段可控制显示,拖动字段可调整顺序。</span></div><div class="sm-column-picker__list" data-column-list>
|
||||
@foreach ($columns as $column)
|
||||
<label class="sm-column-picker__option {{ !empty($column['fixed']) ? 'is-fixed' : '' }}" draggable="{{ empty($column['fixed']) ? 'true' : 'false' }}" data-column-option data-column-key="{{ $column['key'] }}">
|
||||
<input type="checkbox" value="{{ $column['key'] }}" data-column-check @checked(!empty($column['fixed']) || ($column['exportable'] ?? false)) @disabled(!empty($column['fixed']))>
|
||||
@if (empty($column['fixed']))<span class="material-icons-outlined" aria-hidden="true">drag_handle</span>@endif
|
||||
<span>{{ $column['label'] }}</span>
|
||||
@if (!empty($column['fixed']))<small>固定</small>@endif
|
||||
</label>
|
||||
@endforeach
|
||||
</div><div class="sm-column-picker__foot"><button class="sm-button" type="button" data-column-reset><span class="material-icons-outlined" aria-hidden="true">restart_alt</span>恢复默认</button></div></div></div><button class="sm-button" type="button" data-surcharge-export-current><span class="material-icons-outlined" aria-hidden="true">download</span>导出当前列</button><button class="sm-button" type="button" data-surcharge-export-all><span class="material-icons-outlined" aria-hidden="true">file_download</span>导出全部字段</button><span class="sm-card__hint">规则按生效时间应用到后续结算页面</span></div></div>
|
||||
<div class="sm-table-meta"><span>当前筛选结果(全部):<strong data-surcharge-row-count>{{ $rows->count() }}</strong> 条</span><span>规则编号与操作列固定显示;列设置仅保存于当前用户</span></div>
|
||||
<div class="sm-table-wrap" data-surcharge-table-wrap><table class="sm-table" data-finance-table-native data-surcharge-table><thead><tr>
|
||||
@foreach ($columns as $column)
|
||||
<th scope="col" draggable="{{ empty($column['fixed']) ? 'true' : 'false' }}" data-column-header data-column-key="{{ $column['key'] }}" data-column-label="{{ $column['label'] }}" data-fixed="{{ !empty($column['fixed']) ? 'true' : 'false' }}"><span class="sm-table__header">@if (empty($column['fixed']))<span class="sm-table__drag material-icons-outlined" aria-hidden="true">drag_handle</span>@endif<span>{{ $column['label'] }}</span></span></th>
|
||||
@endforeach
|
||||
</tr></thead><tbody>
|
||||
@forelse ($rows as $row)
|
||||
<tr data-surcharge-row data-rule='@json($row)' data-update-url="{{ route('business.courier-surcharge.update', ['quote' => $row['db_id']]) }}"><td>{{ $row['id'] }}</td><td>{{ $row['courier'] }}</td><td>{{ $row['scope'] }}</td><td>{{ $row['reconciler'] }}</td><td>{{ $row['type'] }}</td><td>{{ $row['mode'] }}</td><td class="wrap">{{ $row['standard'] }}</td><td class="wrap">{{ $row['condition'] }}</td><td>{{ $row['effective_at'] }}</td><td><span class="sm-badge {{ $statusClass($row['status']) }}">{{ $row['status'] }}</span></td><td class="wrap">{{ $row['remark'] ?: '—' }}</td><td><div class="sm-actions"><button class="sm-button" type="button" data-surcharge-edit @disabled(!$ready)>编辑</button><form method="POST" action="{{ route('business.courier-surcharge.status', ['quote' => $row['db_id']]) }}">@csrf<input type="hidden" name="status" value="{{ $row['status_code'] === 'active' ? 'inactive' : 'active' }}"><button class="sm-button" type="submit" @disabled(!$ready)>{{ $row['status'] === '已启用' ? '停用' : '启用' }}</button></form></div></td></tr>
|
||||
<tr data-surcharge-row data-rule='@json($row, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT)' data-update-url="{{ route('business.courier-surcharge.update', ['quote' => $row['db_id']]) }}">
|
||||
<td data-column-key="id" data-export-value="{{ $row['id'] }}">{{ $row['id'] }}</td><td data-column-key="courier" data-export-value="{{ $row['courier'] }}">{{ $row['courier'] }}</td><td data-column-key="scope" data-export-value="{{ $row['scope'] }}">{{ $row['scope'] }}</td><td data-column-key="reconciler" data-export-value="{{ $row['reconciler'] }}">{{ $row['reconciler'] }}</td><td data-column-key="type" data-export-value="{{ $row['type'] }}">{{ $row['type'] }}</td><td data-column-key="mode" data-export-value="{{ $row['mode'] }}">{{ $row['mode'] }}</td><td data-column-key="standard" data-export-value="{{ $row['standard'] }}" class="wrap">{{ $row['standard'] }}</td><td data-column-key="condition" data-export-value="{{ $row['condition'] }}" class="wrap">{{ $row['condition'] }}</td><td data-column-key="effective_at" data-export-value="{{ $row['effective_at'] }}">{{ $row['effective_at'] }}</td><td data-column-key="status" data-export-value="{{ $row['status'] }}"><span class="sm-badge {{ $statusClass($row['status']) }}">{{ $row['status'] }}</span></td><td data-column-key="remark" data-export-value="{{ $row['remark'] ?: '—' }}" class="wrap">{{ $row['remark'] ?: '—' }}</td><td data-column-key="actions" data-fixed="true"><div class="sm-actions"><button class="sm-button" type="button" data-surcharge-edit @disabled(!$ready)>编辑</button><form method="POST" action="{{ route('business.courier-surcharge.status', ['quote' => $row['db_id']]) }}">@csrf<input type="hidden" name="status" value="{{ $row['status_code'] === 'active' ? 'inactive' : 'active' }}"><button class="sm-button" type="submit" @disabled(!$ready)>{{ $row['status'] === '已启用' ? '停用' : '启用' }}</button></form></div></td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="12"><div class="sm-empty">没有符合条件的报价规则,请点击“新增报价规则”。</div></td></tr>
|
||||
<tr data-surcharge-empty><td data-empty-cell colspan="12"><div class="sm-empty">没有符合条件的报价规则,请点击“新增报价规则”。</div></td></tr>
|
||||
@endforelse
|
||||
</tbody></table></div>
|
||||
<div class="sm-rule-note"><strong>维护建议:</strong>固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。</div>
|
||||
<div class="sm-rule-note"><strong>维护建议:</strong>固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。当前筛选结果可导出为 CSV;本页暂未启用批量导入,避免在未确认字段规则前覆盖维护数据。</div>
|
||||
</div>
|
||||
|
||||
<div class="sm-modal" role="dialog" aria-modal="true" aria-labelledby="surcharge-modal-title" data-surcharge-modal>
|
||||
@ -115,6 +152,183 @@
|
||||
(() => {
|
||||
const root = document.querySelector('[data-surcharge-maintenance]');
|
||||
if (!root) return;
|
||||
const table = root.querySelector('[data-surcharge-table]');
|
||||
const tableWrap = root.querySelector('[data-surcharge-table-wrap]');
|
||||
const picker = root.querySelector('[data-column-picker]');
|
||||
const columnToggle = picker?.querySelector('[data-column-toggle]');
|
||||
const columnPanel = picker?.querySelector('[data-column-options]') || picker?.querySelector('.sm-column-picker__panel');
|
||||
const columnList = picker?.querySelector('[data-column-list]');
|
||||
const checks = [...(picker?.querySelectorAll('[data-column-check]') || [])];
|
||||
const headers = [...(table?.querySelectorAll('[data-column-header]') || [])];
|
||||
const defaultOrder = headers.map((header) => header.dataset.columnKey);
|
||||
const fixedKeys = new Set(headers.filter((header) => header.dataset.fixed === 'true').map((header) => header.dataset.columnKey));
|
||||
const mutableKeys = defaultOrder.filter((key) => !fixedKeys.has(key));
|
||||
const storageKey = root.dataset.columnStorageKey || 'caiwu:table-columns:courier-surcharge:quotes';
|
||||
const emptyCell = table?.querySelector('[data-empty-cell]');
|
||||
let state = { order: [...defaultOrder], visible: new Set(defaultOrder) };
|
||||
|
||||
const readState = () => {
|
||||
try {
|
||||
const saved = JSON.parse(window.localStorage.getItem(storageKey) || 'null');
|
||||
if (!saved || typeof saved !== 'object') return;
|
||||
const savedOrder = Array.isArray(saved.order) ? saved.order : [];
|
||||
const order = [...new Set(savedOrder.filter((key) => defaultOrder.includes(key)))];
|
||||
defaultOrder.forEach((key) => { if (!order.includes(key)) order.push(key); });
|
||||
const visible = Array.isArray(saved.visible)
|
||||
? new Set(saved.visible.filter((key) => defaultOrder.includes(key)))
|
||||
: new Set(defaultOrder);
|
||||
fixedKeys.forEach((key) => visible.add(key));
|
||||
state = { order, visible };
|
||||
} catch (_) {
|
||||
state = { order: [...defaultOrder], visible: new Set(defaultOrder) };
|
||||
}
|
||||
};
|
||||
|
||||
const writeState = () => {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify({ order: state.order, visible: [...state.visible] }));
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
const orderedKeys = () => {
|
||||
const movable = state.order.filter((key) => !fixedKeys.has(key) && mutableKeys.includes(key));
|
||||
const result = [];
|
||||
let movableIndex = 0;
|
||||
defaultOrder.forEach((key) => {
|
||||
result.push(fixedKeys.has(key) ? key : movable[movableIndex++]);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const moveChildren = (row, keys) => {
|
||||
const cells = new Map([...row.children]
|
||||
.filter((cell) => cell.dataset.columnKey)
|
||||
.map((cell) => [cell.dataset.columnKey, cell]));
|
||||
keys.forEach((key) => { if (cells.has(key)) row.appendChild(cells.get(key)); });
|
||||
};
|
||||
|
||||
const applyColumns = (save = true) => {
|
||||
if (!table) return;
|
||||
const keys = orderedKeys();
|
||||
moveChildren(table.tHead.rows[0], keys);
|
||||
table.querySelectorAll('tbody tr').forEach((row) => moveChildren(row, keys));
|
||||
table.querySelectorAll('[data-column-key]').forEach((cell) => {
|
||||
cell.classList.toggle('is-column-hidden', !state.visible.has(cell.dataset.columnKey));
|
||||
});
|
||||
if (emptyCell) emptyCell.colSpan = Math.max(1, [...state.visible].filter((key) => defaultOrder.includes(key)).length);
|
||||
checks.forEach((check) => {
|
||||
check.checked = state.visible.has(check.value) || fixedKeys.has(check.value);
|
||||
check.disabled = fixedKeys.has(check.value);
|
||||
});
|
||||
if (columnList) {
|
||||
const options = new Map([...columnList.querySelectorAll('[data-column-option]')].map((option) => [option.dataset.columnKey, option]));
|
||||
keys.forEach((key) => { if (options.has(key)) columnList.appendChild(options.get(key)); });
|
||||
}
|
||||
if (save) writeState();
|
||||
};
|
||||
|
||||
const moveMutableKey = (sourceKey, targetKey, before = true) => {
|
||||
if (!sourceKey || !targetKey || sourceKey === targetKey || fixedKeys.has(sourceKey) || fixedKeys.has(targetKey)) return;
|
||||
const order = state.order.filter((key) => key !== sourceKey);
|
||||
const targetIndex = order.indexOf(targetKey);
|
||||
order.splice(Math.max(0, targetIndex + (before ? 0 : 1)), 0, sourceKey);
|
||||
state.order = order;
|
||||
applyColumns();
|
||||
};
|
||||
|
||||
const csvEscape = (value) => {
|
||||
const text = String(value ?? '');
|
||||
const safe = /^[=+\-@]/.test(text) ? `'${text}` : text;
|
||||
return `"${safe.replaceAll('"', '""')}"`;
|
||||
};
|
||||
const downloadCsv = (columns, filename) => {
|
||||
if (!table) return;
|
||||
const header = columns.map((column) => csvEscape(column.label)).join(',');
|
||||
const lines = [...table.querySelectorAll('tbody tr[data-surcharge-row]')].map((row) => columns.map((column) => {
|
||||
const cell = row.querySelector(`[data-column-key="${CSS.escape(column.key)}"]`);
|
||||
return csvEscape(cell?.dataset.exportValue ?? cell?.textContent?.trim() ?? '');
|
||||
}).join(','));
|
||||
const blob = new Blob([`\ufeff${[header, ...lines].join('\n')}`], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
};
|
||||
|
||||
const columnLabels = new Map(headers.map((header) => [header.dataset.columnKey, header.dataset.columnLabel || header.textContent.trim()]));
|
||||
const exportableKeys = defaultOrder.filter((key) => key !== 'actions');
|
||||
const exportColumns = (keys) => keys.map((key) => ({ key, label: columnLabels.get(key) || key }));
|
||||
columnToggle?.addEventListener('click', () => {
|
||||
const opening = columnPanel?.hidden !== false;
|
||||
if (columnPanel) columnPanel.hidden = !opening;
|
||||
columnToggle.setAttribute('aria-expanded', String(opening));
|
||||
});
|
||||
document.addEventListener('click', (event) => {
|
||||
if (picker && !picker.contains(event.target) && columnPanel?.hidden === false) {
|
||||
columnPanel.hidden = true;
|
||||
columnToggle?.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
checks.forEach((check) => check.addEventListener('change', () => {
|
||||
if (fixedKeys.has(check.value)) return;
|
||||
if (check.checked) state.visible.add(check.value); else state.visible.delete(check.value);
|
||||
fixedKeys.forEach((key) => state.visible.add(key));
|
||||
applyColumns();
|
||||
}));
|
||||
picker?.querySelector('[data-column-reset]')?.addEventListener('click', () => {
|
||||
state = { order: [...defaultOrder], visible: new Set(defaultOrder) };
|
||||
applyColumns();
|
||||
});
|
||||
|
||||
const dragSource = (element, sourceKey) => {
|
||||
element.addEventListener('dragstart', (event) => {
|
||||
if (fixedKeys.has(sourceKey)) { event.preventDefault(); return; }
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', sourceKey);
|
||||
element.classList.add('is-dragging');
|
||||
});
|
||||
element.addEventListener('dragend', () => element.classList.remove('is-dragging'));
|
||||
};
|
||||
headers.forEach((header) => {
|
||||
if (header.dataset.fixed === 'true') return;
|
||||
const key = header.dataset.columnKey;
|
||||
dragSource(header, key);
|
||||
header.addEventListener('dragover', (event) => {
|
||||
if (fixedKeys.has(key)) return;
|
||||
event.preventDefault();
|
||||
header.classList.add('is-drop-target');
|
||||
});
|
||||
header.addEventListener('dragleave', () => header.classList.remove('is-drop-target'));
|
||||
header.addEventListener('drop', (event) => {
|
||||
event.preventDefault();
|
||||
header.classList.remove('is-drop-target');
|
||||
moveMutableKey(event.dataTransfer.getData('text/plain'), key, event.offsetX < header.offsetWidth / 2);
|
||||
});
|
||||
});
|
||||
columnList?.querySelectorAll('[data-column-option]').forEach((option) => {
|
||||
if (option.dataset.columnKey === undefined || fixedKeys.has(option.dataset.columnKey)) return;
|
||||
const key = option.dataset.columnKey;
|
||||
dragSource(option, key);
|
||||
option.addEventListener('dragover', (event) => { event.preventDefault(); option.classList.add('is-drop-target'); });
|
||||
option.addEventListener('dragleave', () => option.classList.remove('is-drop-target'));
|
||||
option.addEventListener('drop', (event) => {
|
||||
event.preventDefault();
|
||||
option.classList.remove('is-drop-target');
|
||||
moveMutableKey(event.dataTransfer.getData('text/plain'), key, event.offsetX < option.offsetWidth / 2);
|
||||
});
|
||||
});
|
||||
root.querySelector('[data-surcharge-export-current]')?.addEventListener('click', () => {
|
||||
downloadCsv(exportColumns(orderedKeys().filter((key) => state.visible.has(key) && exportableKeys.includes(key))), 'courier-surcharge-current.csv');
|
||||
});
|
||||
root.querySelector('[data-surcharge-export-all]')?.addEventListener('click', () => {
|
||||
downloadCsv(exportColumns(exportableKeys), 'courier-surcharge-all-fields.csv');
|
||||
});
|
||||
tableWrap?.addEventListener('scroll', () => {
|
||||
tableWrap.classList.toggle('is-scrolled-x', tableWrap.scrollLeft > 0);
|
||||
}, { passive: true });
|
||||
|
||||
const modal = root.querySelector('[data-surcharge-modal]');
|
||||
const typeModal = root.querySelector('[data-type-modal]');
|
||||
const form = root.querySelector('[data-surcharge-form]');
|
||||
@ -140,7 +354,9 @@
|
||||
const selectedTypes = Array.from(form.querySelectorAll('input[name="types[]"]:checked')).map((checkbox) => checkbox.value);
|
||||
if (!selectedTypes.length) { event.preventDefault(); form.querySelector('#surcharge-form-types').focus(); }
|
||||
});
|
||||
document.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; if (modal.classList.contains('is-open')) close(); if (typeModal.classList.contains('is-open')) closeType(); });
|
||||
document.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; if (modal.classList.contains('is-open')) close(); if (typeModal.classList.contains('is-open')) closeType(); if (columnPanel?.hidden === false) { columnPanel.hidden = true; columnToggle?.setAttribute('aria-expanded', 'false'); } });
|
||||
readState();
|
||||
applyColumns(false);
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
$sync = $freightReconciliationSync ?? ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
|
||||
$statusLabels = ['fresh' => '数据新鲜', 'stale' => '数据可能滞后', 'syncing' => '同步中', 'initializing' => '首次同步中', 'pending' => '等待同步', 'failed' => '同步失败', 'initial_failed' => '首次同步失败', 'unavailable' => '镜像不可用'];
|
||||
$money = static fn (mixed $value): string => number_format((float) ($value ?? 0), 2);
|
||||
$number = static fn (mixed $value): string => $value === null || $value === '' ? '-' : rtrim(rtrim(number_format((float) $value, 4, '.', ''), '0'), '.');
|
||||
@endphp
|
||||
|
||||
<div class="courier-reconciliation-page">
|
||||
@ -24,7 +25,7 @@
|
||||
<div class="flex flex-col gap-4 mb-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-800">{{ $page['title'] ?? '运费对账' }}</h1>
|
||||
<p class="mt-1 text-xs text-gray-500">数据来源:JianSun · dbo.fys_express_courier_settlement · 共享业务镜像</p>
|
||||
<p class="mt-1 text-xs text-gray-500">数据来源:JianSun · dbo.jst_order_express_details + dbo.Jst_SendData_View_extension + dbo.jst_salesOutboundInfo · 共享业务镜像</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="rounded-full border border-[#d9d0c2] bg-white px-3 py-1.5 text-[#6f675c]">{{ $statusLabels[$sync['code'] ?? 'unavailable'] ?? '镜像状态未知' }}</span>
|
||||
@ -36,16 +37,9 @@
|
||||
<div class="mb-4 rounded border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ $freightReconciliationError }}</div>
|
||||
@endif
|
||||
|
||||
<div class="mb-5 grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
@foreach ([
|
||||
['label' => '记录数', 'value' => number_format((int) ($summary['count'] ?? 0))],
|
||||
['label' => '应付金额', 'value' => '¥ ' . $money($summary['payable_amount'] ?? 0)],
|
||||
['label' => '已确认金额', 'value' => '¥ ' . $money($summary['confirmed_amount'] ?? 0)],
|
||||
['label' => '实际付款', 'value' => '¥ ' . $money($summary['actual_payment'] ?? 0)],
|
||||
['label' => '对账差额', 'value' => '¥ ' . $money($summary['reconciliation_difference'] ?? 0)],
|
||||
] as $stat)
|
||||
<div class="rounded-lg border border-[#e7e0d5] bg-white px-4 py-3 shadow-sm"><div class="text-xs text-gray-500">{{ $stat['label'] }}</div><div class="mt-1 text-lg font-semibold text-[#4b3a24]">{{ $stat['value'] }}</div></div>
|
||||
@endforeach
|
||||
<div class="mb-5 grid grid-cols-2 gap-3 lg:grid-cols-2">
|
||||
<div class="rounded-lg border border-[#e7e0d5] bg-white px-4 py-3 shadow-sm"><div class="text-xs text-gray-500">记录数</div><div class="mt-1 text-lg font-semibold text-[#4b3a24]">{{ number_format((int) ($summary['count'] ?? 0)) }}</div></div>
|
||||
<div class="rounded-lg border border-[#e7e0d5] bg-white px-4 py-3 shadow-sm"><div class="text-xs text-gray-500">运费合计</div><div class="mt-1 text-lg font-semibold text-[#4b3a24]">¥ {{ $money($summary['freight'] ?? $summary['payable_amount'] ?? 0) }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 rounded-lg border border-[#e7e0d5] bg-white p-4 shadow-sm">
|
||||
@ -54,7 +48,7 @@
|
||||
['name' => 'express_no', 'label' => '快递单号', 'placeholder' => '包含匹配'],
|
||||
['name' => 'express_name', 'label' => '快递名称', 'placeholder' => '包含匹配'],
|
||||
['name' => 'month', 'label' => '月份', 'placeholder' => '如 2026-08'],
|
||||
['name' => 'status', 'label' => '状态', 'placeholder' => '精确匹配'],
|
||||
['name' => 'status', 'label' => '物流状态', 'placeholder' => '精确匹配'],
|
||||
] as $field)
|
||||
<label class="flex min-w-[150px] flex-col gap-1 text-xs text-gray-500"><span>{{ $field['label'] }}</span><input class="recon-control" type="text" name="{{ $field['name'] }}" value="{{ $filters[$field['name']] ?? '' }}" placeholder="{{ $field['placeholder'] }}"></label>
|
||||
@endforeach
|
||||
@ -66,18 +60,25 @@
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-[#e7e0d5] bg-white shadow-sm">
|
||||
<div class="overflow-x-auto"><table class="recon-table min-w-full text-sm"><thead><tr>
|
||||
<th class="text-left">快递单号</th><th class="text-left">快递名称</th><th class="text-left">月份</th><th class="text-left">状态</th>
|
||||
<th class="text-right">对账差额</th><th class="text-right">已确认金额</th><th class="text-right">应付金额</th><th class="text-right">实际付款</th><th class="text-right">预付款</th><th class="text-right">赔偿金额</th><th class="text-right">其他补款</th><th class="text-right">公司付款</th><th class="text-right">个人付款</th><th class="text-left">创建时间</th><th class="text-left">修改时间</th>
|
||||
@foreach (['快递单号','商品ID','发货时间','交易订单号','省','市','区','条形码','快递名称1','快递名称2','数量','商品名称','平台','店铺名称','仓库','体积(cm3)','供应商','长(cm)','宽(cm)','高(cm)','运费','对账重量(kg)','对账体积(cm3)'] as $heading)
|
||||
<th class="text-left">{{ $heading }}</th>
|
||||
@endforeach
|
||||
</tr></thead><tbody>
|
||||
@forelse ($freightReconciliations ?? [] as $row)
|
||||
<tr><td class="font-mono text-xs text-[#4b3a24]">{{ $row['waybill'] ?? '-' }}</td><td>{{ $row['expressname'] ?: ($row['courier_company_name'] ?? '-') }}</td><td>{{ $row['month'] ?? '-' }}</td><td>{{ $row['status'] ?? '-' }}</td>
|
||||
@foreach (['reconciliation_difference','confirmed_amount','payable_amount','actual_payment','advance_payment','compensation_amount','other_replenishment','corporate_payment_amount','personal_payment_amount'] as $amount)
|
||||
<td class="text-right tabular-nums">{{ $row[$amount] === null ? '-' : $money($row[$amount]) }}</td>
|
||||
<tr>
|
||||
<td class="font-mono text-xs text-[#4b3a24]">{{ $row['waybill'] ?? '-' }}</td>
|
||||
<td>{{ $row['product_id'] ?? '-' }}</td>
|
||||
<td>{{ $row['send_time'] ?? '-' }}</td>
|
||||
<td>{{ $row['transaction_order_no'] ?? '-' }}</td>
|
||||
<td>{{ $row['province'] ?? '-' }}</td><td>{{ $row['city'] ?? '-' }}</td><td>{{ $row['district'] ?? '-' }}</td><td>{{ $row['barcode'] ?? '-' }}</td>
|
||||
<td>{{ $row['express_name'] ?? '-' }}</td><td>{{ $row['express_name_2'] ?? '-' }}</td><td>{{ $row['quantity'] ?? '-' }}</td>
|
||||
<td>{{ $row['product_name'] ?? '-' }}</td><td>{{ $row['platform'] ?? '-' }}</td><td>{{ $row['shop_name'] ?? '-' }}</td><td>{{ $row['warehouse'] ?? '-' }}</td><td>{{ $row['supplier'] ?? '-' }}</td>
|
||||
@foreach (['volume','length','width','height','freight','reconciliation_weight','reconciliation_volume'] as $numberField)
|
||||
<td class="text-right tabular-nums">{{ $row[$numberField] === null ? '-' : ($numberField === 'freight' ? $money($row[$numberField]) : $number($row[$numberField])) }}</td>
|
||||
@endforeach
|
||||
<td>{{ $row['create_time'] ?? '-' }}</td><td>{{ $row['modify_time'] ?? '-' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="15" class="px-4 py-16 text-center text-sm text-gray-500">@if ($freightReconciliationPending ?? false) 正在等待镜像首次同步完成… @else 暂无符合条件的运费对账数据 @endif</td></tr>
|
||||
<tr><td colspan="23" class="px-4 py-16 text-center text-sm text-gray-500">@if ($freightReconciliationPending ?? false) 正在等待物流明细镜像首次同步完成… @else 暂无符合条件的运费对账数据 @endif</td></tr>
|
||||
@endforelse
|
||||
</tbody></table></div>
|
||||
@if (($freightReconciliations ?? null)?->hasPages())
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
@php
|
||||
$rows = $billOffsetRows ?? null;
|
||||
$filters = $billOffsetFilters ?? [];
|
||||
$summary = $billOffsetSummary ?? ['amount' => null, 'count' => 0];
|
||||
$status = $billOffsetStatus ?? null;
|
||||
$pageAmount = $billOffsetPageAmount ?? null;
|
||||
$columns = [
|
||||
'internal_order_no' => '内部订单号',
|
||||
'online_order_no' => '线上订单号',
|
||||
@ -59,10 +59,7 @@
|
||||
<div class="upstream-data-actions">
|
||||
<button type="submit" class="upstream-data-button upstream-data-button--primary"><span class="material-icons-outlined" aria-hidden="true">search</span><span>查询</span></button>
|
||||
<a href="{{ route('business.pages.show', ['section' => 'order-data', 'page' => 'bill-offset']) }}" class="upstream-data-button"><span class="material-icons-outlined" aria-hidden="true">refresh</span><span>重置</span></a>
|
||||
@php
|
||||
$hasRows = $rows && $rows->count() > 0;
|
||||
@endphp
|
||||
<a id="bill-offset-export-button" class="upstream-data-button{{ !$hasRows ? ' is-disabled' : '' }}" href="{{ route('business.bill-offset.export', $filters) }}" @if (!$hasRows) aria-disabled="true" tabindex="-1" @endif>
|
||||
<a id="bill-offset-export-button" class="upstream-data-button{{ (!$rows || $rows->count() === 0) ? ' is-disabled' : '' }}" href="{{ route('business.bill-offset.export', $filters) }}" @if (!$rows || $rows->count() === 0) aria-disabled="true" tabindex="-1" @endif>
|
||||
<span class="material-icons-outlined" aria-hidden="true">download</span><span>导出全部</span>
|
||||
</a>
|
||||
</div>
|
||||
@ -74,7 +71,8 @@
|
||||
|
||||
<div class="upstream-data-table-panel">
|
||||
<div class="bill-offset-summary" aria-label="账单冲减汇总">
|
||||
<div class="bill-offset-summary__item"><span>本页 500112 冲减收入合计</span><strong class="bill-offset-summary__value">{{ $pageAmount === null || $pageAmount === '' ? '-' : number_format((float) $pageAmount, 2) }}</strong></div>
|
||||
<div class="bill-offset-summary__item"><span>本页记录数</span><strong class="bill-offset-summary__value">{{ number_format((int) ($summary['count'] ?? 0)) }}</strong></div>
|
||||
<div class="bill-offset-summary__item"><span>本页 500112 冲减收入合计</span><strong class="bill-offset-summary__value">{{ ($summary['amount'] ?? null) === null ? '-' : number_format((float) $summary['amount'], 2) }}</strong></div>
|
||||
</div>
|
||||
@if ($status)
|
||||
<div class="bill-offset-sync">数据状态:{{ strtoupper((string) $status) }}</div>
|
||||
@ -87,7 +85,7 @@
|
||||
<tr>
|
||||
@foreach ($columns as $field => $header)
|
||||
@php $value = data_get($row, $field); @endphp
|
||||
<td data-export-value="{{ $value ?? '' }}" data-export-number="{{ $field === 'account_500112' ? 'true' : 'false' }}" @class(['is-number' => $field === 'account_500112'])>{{ $field === 'account_500112' ? (($value === null || $value === '') ? '-' : number_format((float) $value, 2)) : (($value === null || $value === '') ? '-' : $value) }}</td>
|
||||
<td data-export-value="{{ $value ?? '' }}" data-export-number="{{ $field === 'account_500112' ? 'true' : 'false' }}" @class(['is-number' => $field === 'account_500112'])>{{ ($value === null || $value === '') ? '-' : ($field === 'account_500112' ? number_format((float) $value, 2) : $value) }}</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
@empty
|
||||
|
||||
@ -0,0 +1,712 @@
|
||||
@once
|
||||
<style>
|
||||
/* Shared finance table contract: one compact surface and one scroll owner. */
|
||||
.finance-table-surface,
|
||||
.directory-table-page,
|
||||
.business-content-section {
|
||||
color: var(--vip-content-ink, #171717);
|
||||
font-family: "JetBrains Mono", "Microsoft YaHei", "PingFang SC", monospace;
|
||||
}
|
||||
|
||||
.business-content-section {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--vip-surface, #ffffff);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.business-tab-bar {
|
||||
min-height: 3rem;
|
||||
gap: 1.25rem;
|
||||
padding: 0 1rem;
|
||||
border-color: var(--vip-border, #d8d2c7);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--vip-surface, #ffffff);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.business-tab-link {
|
||||
min-height: 3rem;
|
||||
gap: 0.4rem;
|
||||
color: var(--vip-content-muted, #595959);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.business-tab-link:hover,
|
||||
.business-tab-link.is-active {
|
||||
color: var(--vip-content-accent-dark, #856632);
|
||||
}
|
||||
|
||||
.business-page--marketing-account-bills .business-tab-bar {
|
||||
min-height: 3rem;
|
||||
gap: 1.25rem;
|
||||
padding: 0 1rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.business-page--marketing-account-bills .business-tab-link {
|
||||
min-height: 3rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.business-page--marketing-account-bills .business-content-section {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.finance-table-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--vip-content-bg, #f8f6f1);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.finance-table-controls__button,
|
||||
.finance-table-controls select {
|
||||
display: inline-flex;
|
||||
min-height: 2.125rem;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--vip-surface, #ffffff);
|
||||
color: var(--vip-content-ink, #171717);
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.finance-table-controls__button {
|
||||
padding: 0 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.finance-table-controls__button:hover,
|
||||
.finance-table-controls__button:focus-visible,
|
||||
.finance-table-controls select:focus-visible {
|
||||
border-color: var(--vip-content-accent-dark, #856632);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.2);
|
||||
}
|
||||
|
||||
.finance-table-controls__button--primary {
|
||||
border-color: var(--vip-content-accent-dark, #856632);
|
||||
background: var(--vip-content-accent-dark, #856632);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.finance-table-controls select {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.finance-table-controls__mode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--vip-content-muted, #595959);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.finance-table-controls__mode select { min-width: 9.5rem; }
|
||||
|
||||
.finance-table-controls [data-directory-export],
|
||||
.finance-table-controls [data-directory-export-all],
|
||||
.finance-table-controls [data-finance-server-export] {
|
||||
display: inline-flex;
|
||||
min-height: 2.125rem;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0 0.75rem;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--vip-surface, #ffffff);
|
||||
color: var(--vip-content-ink, #171717);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.finance-table-controls [data-directory-export-all],
|
||||
.finance-table-controls [data-finance-server-export] { border-color: var(--vip-content-accent-dark, #856632); }
|
||||
|
||||
.finance-table-controls [data-directory-export]:hover,
|
||||
.finance-table-controls [data-directory-export-all]:hover,
|
||||
.finance-table-controls [data-finance-server-export]:hover { background: var(--vip-content-accent-soft, #f7f0de); }
|
||||
|
||||
.finance-table-controls .is-column-hidden { display: none !important; }
|
||||
|
||||
.finance-table-field-picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.finance-table-field-picker__panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.35rem);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(10rem, 1fr));
|
||||
gap: 0.25rem 0.75rem;
|
||||
min-width: 25rem;
|
||||
max-height: min(24rem, 60vh);
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--vip-surface, #ffffff);
|
||||
box-shadow: 0 4px 8px rgba(23, 23, 23, 0.1);
|
||||
}
|
||||
|
||||
.finance-table-field-picker__panel[hidden] { display: none; }
|
||||
|
||||
.finance-table-field-picker__option {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 1.75rem;
|
||||
color: var(--vip-content-ink, #171717);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.finance-table-field-picker__option.is-dragging { opacity: 0.45; }
|
||||
.finance-table-field-picker__option input { flex: 0 0 auto; accent-color: var(--vip-content-accent, #b9954f); }
|
||||
.finance-table-field-picker__option span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.finance-table-field-picker__actions { display: flex; grid-column: 1 / -1; gap: 0.35rem; padding-top: 0.35rem; border-top: 1px solid var(--vip-border, #d8d2c7); }
|
||||
.finance-table-field-picker__actions button { min-height: 1.75rem; padding: 0 0.5rem; border: 1px solid var(--vip-border, #d8d2c7); border-radius: 0.35rem; background: #fff; color: var(--vip-content-accent-dark, #856632); cursor: pointer; font: inherit; }
|
||||
|
||||
.finance-table-scroll-owner,
|
||||
.directory-table-scroll,
|
||||
.upstream-data-table-scroll,
|
||||
.bill-table-wrapper,
|
||||
.tb-scroll,
|
||||
.ab-scroll,
|
||||
.marketing-bills-table-wrapper,
|
||||
.jd-wallet-table-wrap,
|
||||
.alibaba-bill-table-wrapper,
|
||||
.zhongcai-table-wrapper {
|
||||
max-height: calc(100vh - 21rem);
|
||||
overflow: auto;
|
||||
scrollbar-color: var(--vip-content-accent, #b9954f) #efede8;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
|
||||
[data-finance-table-ready] thead th,
|
||||
.directory-table thead th,
|
||||
.upstream-data-table thead th,
|
||||
.bill-table thead th,
|
||||
.tb-table thead th,
|
||||
.ab-table thead th,
|
||||
.marketing-bills-table thead th,
|
||||
.jd-wallet-table thead th,
|
||||
.alibaba-bill-table thead th,
|
||||
.zhongcai-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 4;
|
||||
background: var(--vip-content-accent-soft, #f7f0de);
|
||||
color: var(--vip-content-ink, #171717);
|
||||
}
|
||||
|
||||
.tb-table thead .tb-group th,
|
||||
.ab-table thead .ab-group th {
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.tb-table thead .tb-col th,
|
||||
.ab-table thead .ab-col th { top: 2.25rem; z-index: 4; background: #fffdf8; }
|
||||
|
||||
[data-finance-fixed="true"] {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 6;
|
||||
background: var(--vip-surface, #ffffff);
|
||||
}
|
||||
|
||||
thead [data-finance-fixed="true"] { background: var(--vip-content-accent-soft, #f7f0de); }
|
||||
|
||||
[data-finance-column-key].is-column-hidden { display: none !important; }
|
||||
|
||||
.finance-table-controls [data-finance-server-export][disabled],
|
||||
.finance-table-controls [data-directory-export][disabled],
|
||||
.finance-table-controls [data-directory-export-all][disabled] { opacity: 0.55; cursor: wait; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.business-content-section { padding: 0.75rem; }
|
||||
.finance-table-field-picker__panel { right: auto; left: 0; min-width: min(25rem, calc(100vw - 2rem)); grid-template-columns: minmax(0, 1fr); }
|
||||
.finance-table-controls__button, .finance-table-controls select { flex: 1 1 9rem; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@php
|
||||
$financeTableContext = [
|
||||
'user' => auth()->id() ?? 'guest',
|
||||
'section' => $section['slug'] ?? '',
|
||||
'page' => $page['slug'] ?? '',
|
||||
'tab' => $activeTab['slug'] ?? '',
|
||||
];
|
||||
@endphp
|
||||
<script>
|
||||
(() => {
|
||||
const context = @json($financeTableContext);
|
||||
window.__CAIWU_FINANCE_CONTEXT = context;
|
||||
|
||||
const selector = [
|
||||
'[data-finance-table]', '.directory-table', '.upstream-data-table', '.biz-table',
|
||||
'.business-table', '.after-sales-refund-table', '.refund-data-table', '.small-payment-table',
|
||||
'.tb-table', '.ab-table', '.marketing-bills-table', '.bill-table', '.biz-table',
|
||||
'.jd-wallet-table', '.alibaba-bill-table', '.zhongcai-table', '.marketing-balance-table',
|
||||
'.installation-detail-table', '.courier-information-table', '.edge-banding-table',
|
||||
'.agreement-table', '.quote-table', '.recon-table', '.sm-table', '.ding-payable-table',
|
||||
].join(',');
|
||||
const tableStates = new WeakMap();
|
||||
const tableNames = new WeakSet();
|
||||
const routeContext = () => {
|
||||
const parts = window.location.pathname.split('/').filter(Boolean);
|
||||
const workspaceIndex = parts.indexOf('workspace');
|
||||
return workspaceIndex < 0 ? context : {
|
||||
user: context.user,
|
||||
section: parts[workspaceIndex + 1] || context.section,
|
||||
page: parts[workspaceIndex + 2] || context.page,
|
||||
tab: parts[workspaceIndex + 3] || '',
|
||||
};
|
||||
};
|
||||
const keyFor = (table) => {
|
||||
if (table.dataset.financeTableKey) return table.dataset.financeTableKey;
|
||||
const currentContext = routeContext();
|
||||
const parent = table.closest('[data-directory-page], [data-refund-page], [data-finance-table-surface], .business-content-section') || table.parentElement;
|
||||
const name = table.getAttribute('aria-label') || table.dataset.financeTableName || table.className || 'table';
|
||||
const siblings = parent ? [...parent.querySelectorAll(selector)].filter((candidate) => candidate.matches(selector)) : [table];
|
||||
const index = Math.max(0, siblings.indexOf(table));
|
||||
const scope = parent?.dataset.directoryPage || parent?.dataset.refundPage || name;
|
||||
table.dataset.financeTableKey = `${currentContext.user}:${currentContext.section}:${currentContext.page}:${currentContext.tab}:${scope}:${index}`;
|
||||
return table.dataset.financeTableKey;
|
||||
};
|
||||
const storageKey = (table, suffix) => `caiwu.finance.${keyFor(table)}.${suffix}`;
|
||||
const slug = (text) => String(text || '').trim().toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, '_').replace(/^_|_$/g, '') || 'column';
|
||||
const csvText = (value) => {
|
||||
const text = String(value ?? '').trim();
|
||||
if (/^\d{12,}$/.test(text)) return `="${text}"`;
|
||||
return /^[=+\-@]/u.test(text) ? `'${text}` : text;
|
||||
};
|
||||
const csvEscape = (value) => `"${csvText(value).replaceAll('"', '""')}"`;
|
||||
const cellText = (cell) => {
|
||||
const passwordStatus = cell?.querySelector('[data-password-status]');
|
||||
if (passwordStatus) return passwordStatus.textContent.trim();
|
||||
return cell?.dataset.exportValue ?? cell?.textContent.trim() ?? '';
|
||||
};
|
||||
const downloadCsv = (filename, lines) => {
|
||||
const blob = new Blob([`\ufeff${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
};
|
||||
|
||||
function leafHeaders(table) {
|
||||
const rows = [...table.tHead?.rows || []];
|
||||
if (!rows.length) return [];
|
||||
return rows.reduce((best, row) => row.cells.length > best.length ? [...row.cells] : best, []);
|
||||
}
|
||||
|
||||
function headerLeafKeys(table) {
|
||||
const rows = [...table.tHead?.rows || []];
|
||||
if (rows.length < 2) return leafHeaders(table).map((cell) => cell.dataset.financeColumnKey);
|
||||
const leafRow = rows[rows.length - 1];
|
||||
return [...leafRow.cells].map((cell) => cell.dataset.financeColumnKey).filter(Boolean);
|
||||
}
|
||||
|
||||
function prepareColumns(table) {
|
||||
const headers = leafHeaders(table);
|
||||
const headerRows = [...table.tHead?.rows || []];
|
||||
const scrollOwner = table.closest('.finance-table-scroll-owner, .directory-table-scroll, .upstream-data-table-scroll, .bill-table-wrapper, .tb-scroll, .ab-scroll, .marketing-bills-table-wrapper, .jd-wallet-table-wrap, .alibaba-bill-table-wrapper, .zhongcai-table-wrapper') || table.parentElement;
|
||||
scrollOwner?.classList.add('finance-table-scroll-owner');
|
||||
headers.forEach((cell, index) => {
|
||||
if (!cell.dataset.financeColumnKey) {
|
||||
cell.dataset.financeColumnKey = cell.dataset.columnKey || cell.dataset.field || cell.dataset.exportField || `${slug(cell.textContent)}_${index}`;
|
||||
}
|
||||
cell.dataset.financeColumnIndex = String(index);
|
||||
if (index === 0 && (cell.querySelector('input[type="checkbox"]') || ['#', '序号'].includes(cell.textContent.trim()))) cell.dataset.financeFixed = 'true';
|
||||
});
|
||||
const keys = headers.map((cell) => cell.dataset.financeColumnKey);
|
||||
const leafRow = headerRows[headerRows.length - 1];
|
||||
if (leafRow) {
|
||||
[...leafRow.cells].forEach((cell, index) => {
|
||||
cell.dataset.financeColumnKey = keys[index];
|
||||
cell.dataset.financeColumnIndex = String(index);
|
||||
if (headers[index]?.dataset.financeFixed === 'true') cell.dataset.financeFixed = 'true';
|
||||
});
|
||||
}
|
||||
const groupRows = headerRows.slice(0, -1).map((row) => {
|
||||
let offset = 0;
|
||||
return [...row.cells].map((cell, groupIndex) => {
|
||||
const span = Math.max(Number(cell.colSpan || 1), 1);
|
||||
const group = { index: groupIndex, label: cell.textContent.trim(), span, template: cell.cloneNode(true) };
|
||||
for (let i = offset; i < Math.min(offset + span, keys.length); i += 1) {
|
||||
headers[i].dataset.financeGroup = String(groupIndex);
|
||||
}
|
||||
offset += span;
|
||||
cell.dataset.financeGroupHeader = 'true';
|
||||
return group;
|
||||
});
|
||||
});
|
||||
table.querySelectorAll('tbody tr').forEach((row) => {
|
||||
if (row.classList.contains('is-empty') || row.children.length !== keys.length) return;
|
||||
[...row.children].forEach((cell, index) => {
|
||||
cell.dataset.financeColumnKey = cell.dataset.financeColumnKey || keys[index];
|
||||
if (headers[index]?.dataset.financeFixed === 'true') cell.dataset.financeFixed = 'true';
|
||||
});
|
||||
});
|
||||
tableStates.set(table, { headers, keys, leafRow, groupRows });
|
||||
table.dataset.financeTableReady = 'true';
|
||||
window.requestAnimationFrame(() => applyStickyOffsets(table));
|
||||
return { headers, keys };
|
||||
}
|
||||
|
||||
function applyStickyOffsets(table) {
|
||||
let offset = 0;
|
||||
[...table.tHead?.rows || []].forEach((row) => {
|
||||
[...row.cells].forEach((cell) => { cell.style.top = `${offset}px`; });
|
||||
offset += row.getBoundingClientRect().height || 36;
|
||||
});
|
||||
}
|
||||
|
||||
function reorderCells(table, order) {
|
||||
const byKey = (row) => new Map([...row.children].map((cell) => [cell.dataset.financeColumnKey, cell]));
|
||||
const rows = [...table.tHead?.rows || []];
|
||||
const state = tableStates.get(table);
|
||||
const leafRow = rows[rows.length - 1];
|
||||
if (leafRow && leafRow.cells.length === order.length) {
|
||||
const map = byKey(leafRow);
|
||||
order.forEach((key) => { const cell = map.get(key); if (cell) leafRow.appendChild(cell); });
|
||||
}
|
||||
rows.slice(0, -1).forEach((row, rowIndex) => {
|
||||
const definitions = state?.groupRows?.[rowIndex];
|
||||
if (!definitions?.length) return;
|
||||
const byGroup = new Map(definitions.map((group) => [String(group.index), group]));
|
||||
const runs = [];
|
||||
order.forEach((key) => {
|
||||
const group = String(state.headers.find((header) => header.dataset.financeColumnKey === key)?.dataset.financeGroup ?? '');
|
||||
const previous = runs[runs.length - 1];
|
||||
if (previous && previous.group === group) previous.span += 1;
|
||||
else runs.push({ group, span: 1 });
|
||||
});
|
||||
row.replaceChildren(...runs.map((run) => {
|
||||
const definition = byGroup.get(run.group);
|
||||
const cell = definition?.template?.cloneNode(true) || document.createElement('th');
|
||||
cell.colSpan = run.span;
|
||||
cell.dataset.financeGroupHeader = 'true';
|
||||
return cell;
|
||||
}));
|
||||
});
|
||||
table.querySelectorAll('tbody tr').forEach((row) => {
|
||||
if (row.classList.contains('is-empty') || row.children.length !== order.length) return;
|
||||
const map = byKey(row);
|
||||
order.forEach((key) => { const cell = map.get(key); if (cell) row.appendChild(cell); });
|
||||
});
|
||||
window.requestAnimationFrame(() => applyStickyOffsets(table));
|
||||
}
|
||||
|
||||
function setVisibility(table, checks, save = true) {
|
||||
const selected = new Set(checks.filter((check) => check.checked).map((check) => check.value));
|
||||
table.dataset.columnsReady = 'true';
|
||||
table.querySelectorAll('[data-finance-column-key]').forEach((cell) => {
|
||||
if (cell.dataset.financeFixed === 'true') return;
|
||||
cell.classList.toggle('is-column-hidden', !selected.has(cell.dataset.financeColumnKey));
|
||||
});
|
||||
const state = tableStates.get(table);
|
||||
if (state?.groupRows?.length) {
|
||||
const order = [...state.leafRow?.cells || []].map((cell) => cell.dataset.financeColumnKey).filter(Boolean);
|
||||
state.groupRows.forEach((definitions, rowIndex) => {
|
||||
const row = table.tHead?.rows?.[rowIndex];
|
||||
if (!row) return;
|
||||
const runs = [];
|
||||
order.forEach((key) => {
|
||||
const header = state.headers.find((cell) => cell.dataset.financeColumnKey === key);
|
||||
if (!header || (!selected.has(key) && header.dataset.financeFixed !== 'true')) return;
|
||||
const group = String(header.dataset.financeGroup ?? '');
|
||||
const previous = runs[runs.length - 1];
|
||||
if (previous && previous.group === group) previous.span += 1;
|
||||
else runs.push({ group, span: 1 });
|
||||
});
|
||||
const byGroup = new Map(definitions.map((definition) => [String(definition.index), definition]));
|
||||
row.replaceChildren(...runs.map((run) => {
|
||||
const template = byGroup.get(run.group)?.template;
|
||||
const cell = template?.cloneNode(true) || document.createElement('th');
|
||||
cell.colSpan = run.span;
|
||||
cell.dataset.financeGroupHeader = 'true';
|
||||
return cell;
|
||||
}));
|
||||
});
|
||||
}
|
||||
window.requestAnimationFrame(() => applyStickyOffsets(table));
|
||||
if (save) {
|
||||
try { localStorage.setItem(storageKey(table, 'visibility'), JSON.stringify([...selected])); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function createPicker(table, headers, host) {
|
||||
let picker = host.querySelector('[data-finance-field-picker], [data-directory-field-picker]');
|
||||
const isLegacyDirectoryPicker = picker?.hasAttribute('data-directory-field-picker') === true;
|
||||
if (!picker) {
|
||||
picker = document.createElement('div');
|
||||
picker.className = 'finance-table-field-picker';
|
||||
picker.dataset.financeFieldPicker = 'true';
|
||||
picker.innerHTML = '<button type="button" class="finance-table-controls__button" aria-expanded="false">列设置</button><div class="finance-table-field-picker__panel" role="group" aria-label="选择展示字段" hidden></div>';
|
||||
host.prepend(picker);
|
||||
}
|
||||
picker.dataset.financeFieldPicker = 'true';
|
||||
picker.classList.add('finance-table-field-picker');
|
||||
const toggle = picker.querySelector('button');
|
||||
const panel = picker.querySelector('.finance-table-field-picker__panel, .directory-field-picker__panel');
|
||||
panel.classList.add('finance-table-field-picker__panel');
|
||||
let checks = [...panel.querySelectorAll('input[data-finance-field-check], input[data-directory-field-check]')];
|
||||
if (!checks.length) {
|
||||
headers.forEach((header) => {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'finance-table-field-picker__option';
|
||||
label.draggable = true;
|
||||
const key = header.dataset.financeColumnKey;
|
||||
const fixed = header.dataset.financeFixed === 'true';
|
||||
label.dataset.financeOptionKey = key;
|
||||
label.innerHTML = `<input type="checkbox" value="${key.replaceAll('"', '"')}" data-finance-field-check ${fixed ? 'checked disabled' : 'checked'}><span>${header.textContent.trim() || '未命名字段'}</span>`;
|
||||
panel.appendChild(label);
|
||||
});
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'finance-table-field-picker__actions';
|
||||
actions.innerHTML = '<button type="button" data-finance-all>全选</button><button type="button" data-finance-none>全不选</button><button type="button" data-finance-reset>恢复默认</button>';
|
||||
panel.appendChild(actions);
|
||||
}
|
||||
if (!panel.querySelector('[data-finance-all]')) {
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'finance-table-field-picker__actions';
|
||||
actions.innerHTML = '<button type="button" data-finance-all>全选</button><button type="button" data-finance-none>全不选</button><button type="button" data-finance-reset>恢复默认</button>';
|
||||
panel.appendChild(actions);
|
||||
}
|
||||
checks = [...panel.querySelectorAll('input[data-finance-field-check], input[data-directory-field-check]')];
|
||||
const defaults = headers.map((header) => header.dataset.financeColumnKey);
|
||||
const fixedKeys = headers.filter((header) => header.dataset.financeFixed === 'true').map((header) => header.dataset.financeColumnKey);
|
||||
const defaultVisible = new Set(checks.filter((check) => check.defaultChecked || check.disabled).map((check) => check.value));
|
||||
const applyOrder = (order, save = true) => {
|
||||
const valid = fixedKeys.concat(order.filter((key) => defaults.includes(key) && !fixedKeys.includes(key)));
|
||||
defaults.forEach((key) => { if (!valid.includes(key)) valid.push(key); });
|
||||
reorderCells(table, valid);
|
||||
[...panel.querySelectorAll('[data-finance-option-key], .directory-field-picker__option')].sort((a, b) => valid.indexOf(a.dataset.financeOptionKey || a.querySelector('input')?.value) - valid.indexOf(b.dataset.financeOptionKey || b.querySelector('input')?.value)).forEach((option) => panel.insertBefore(option, panel.querySelector('.finance-table-field-picker__actions')));
|
||||
if (save) localStorage.setItem(storageKey(table, 'order'), JSON.stringify(valid));
|
||||
};
|
||||
try {
|
||||
const savedVisibility = JSON.parse(localStorage.getItem(storageKey(table, 'visibility')) || 'null');
|
||||
if (Array.isArray(savedVisibility)) checks.forEach((check) => { if (!check.disabled) check.checked = savedVisibility.includes(check.value); });
|
||||
const savedOrder = JSON.parse(localStorage.getItem(storageKey(table, 'order')) || 'null');
|
||||
applyOrder(Array.isArray(savedOrder) ? savedOrder : defaults, false);
|
||||
} catch (_) {}
|
||||
setVisibility(table, checks, false);
|
||||
if (!isLegacyDirectoryPicker) {
|
||||
toggle.addEventListener('click', () => { const open = panel.hidden; panel.hidden = !open; toggle.setAttribute('aria-expanded', String(open)); });
|
||||
}
|
||||
checks.forEach((check) => check.addEventListener('change', () => setVisibility(table, checks)));
|
||||
panel.querySelector('[data-finance-all]')?.addEventListener('click', () => { checks.forEach((check) => { if (!check.disabled) check.checked = true; }); setVisibility(table, checks); });
|
||||
panel.querySelector('[data-finance-none]')?.addEventListener('click', () => { checks.forEach((check) => { if (!check.disabled) check.checked = false; }); setVisibility(table, checks); });
|
||||
panel.querySelector('[data-finance-reset]')?.addEventListener('click', () => { checks.forEach((check) => { if (!check.disabled) check.checked = defaultVisible.has(check.value); }); applyOrder(defaults); setVisibility(table, checks); });
|
||||
let dragging = null;
|
||||
panel.querySelectorAll('[data-finance-option-key], .directory-field-picker__option').forEach((option) => {
|
||||
option.dataset.financeOptionKey = option.dataset.financeOptionKey || option.querySelector('input')?.value;
|
||||
if (fixedKeys.includes(option.dataset.financeOptionKey)) {
|
||||
option.draggable = false;
|
||||
return;
|
||||
}
|
||||
option.draggable = true;
|
||||
option.addEventListener('dragstart', () => { dragging = option; option.classList.add('is-dragging'); });
|
||||
option.addEventListener('dragend', () => { option.classList.remove('is-dragging'); dragging = null; });
|
||||
option.addEventListener('dragover', (event) => { event.preventDefault(); if (dragging && dragging !== option) panel.insertBefore(dragging, option); });
|
||||
option.addEventListener('drop', () => { const order = [...panel.querySelectorAll('[data-finance-option-key]')].map((item) => item.dataset.financeOptionKey); applyOrder(order); });
|
||||
});
|
||||
return picker;
|
||||
}
|
||||
|
||||
function ensureControls(table, headers) {
|
||||
const existingDirectoryTools = table.closest('[data-directory-page]')?.querySelector('[data-directory-tools]');
|
||||
const root = table.closest('.upstream-data-table-panel, .directory-table-page, .tb-panel, .ab-panel, .marketing-bills-table-wrapper, .bill-table-wrapper, .jd-wallet-table-wrap, .alibaba-bill-table-wrapper, .zhongcai-table-wrapper') || table.parentElement;
|
||||
if (!root) return;
|
||||
let controls = existingDirectoryTools || root.querySelector('[data-finance-controls]');
|
||||
if (!controls) {
|
||||
controls = document.createElement('div');
|
||||
controls.className = 'finance-table-controls';
|
||||
controls.dataset.financeControls = 'true';
|
||||
root.insertBefore(controls, root.firstChild);
|
||||
}
|
||||
createPicker(table, headers, controls);
|
||||
if (existingDirectoryTools) {
|
||||
controls.dataset.financeExportEnhanced = 'true';
|
||||
const currentPage = controls.querySelector('[data-directory-export]');
|
||||
const allRows = controls.querySelector('[data-directory-export-all]');
|
||||
if (currentPage) currentPage.textContent = '当前页·显示列';
|
||||
if (allRows) allRows.textContent = '全部数据·显示列';
|
||||
const ensureDirectoryExport = (selector, label, allRows) => {
|
||||
const button = controls.querySelector(selector) || document.createElement('button');
|
||||
if (!button.parentElement) controls.appendChild(button);
|
||||
button.type = 'button';
|
||||
button.dataset.financeExportAllFields = 'true';
|
||||
if (allRows) button.dataset.directoryExportAll = 'true';
|
||||
button.textContent = label;
|
||||
return button;
|
||||
};
|
||||
ensureDirectoryExport('[data-finance-export-all-fields]:not([data-directory-export-all])', '当前页·全部字段', false);
|
||||
ensureDirectoryExport('[data-finance-export-all-fields][data-directory-export-all]', '全部数据·全部字段', true);
|
||||
}
|
||||
if (table.dataset.financeExportBase && !controls.querySelector('[data-finance-server-export]')) {
|
||||
const addServerExport = (label, allColumns) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.dataset.financeServerExport = allColumns ? 'all' : 'visible';
|
||||
button.textContent = label;
|
||||
controls.appendChild(button);
|
||||
};
|
||||
addServerExport('导出当前显示列', false);
|
||||
addServerExport('导出全部可导出字段', true);
|
||||
}
|
||||
}
|
||||
|
||||
function tableRows(table, includeHiddenRows, query) {
|
||||
const normalized = String(query || '').trim().toLocaleLowerCase();
|
||||
return [...table.querySelectorAll('tbody tr:not(.is-empty)')].filter((row) => {
|
||||
if (!includeHiddenRows && row.hidden) return false;
|
||||
return !normalized || row.textContent.toLocaleLowerCase().includes(normalized);
|
||||
});
|
||||
}
|
||||
|
||||
function orderedCells(row, order, allColumns) {
|
||||
const cells = [...row.children];
|
||||
const byKey = new Map(cells.map((cell) => [cell.dataset.financeColumnKey || cell.dataset.columnKey, cell]));
|
||||
const keys = allColumns ? order : order.filter((key) => !byKey.get(key)?.classList.contains('is-column-hidden'));
|
||||
return keys.map((key) => byKey.get(key)).filter(Boolean);
|
||||
}
|
||||
|
||||
async function exportDirectory(table, button, includeHiddenRows, allColumns) {
|
||||
const page = table.closest('[data-directory-page]');
|
||||
const search = page?.querySelector('[data-directory-search]');
|
||||
const headers = leafHeaders(table);
|
||||
const selected = new Set([...table.querySelectorAll('thead th[data-finance-column-key]')]
|
||||
.filter((cell) => !cell.classList.contains('is-column-hidden'))
|
||||
.map((cell) => cell.dataset.financeColumnKey));
|
||||
const order = headers.map((header) => header.dataset.financeColumnKey);
|
||||
const exportOrder = allColumns ? order : order.filter((key) => selected.has(key));
|
||||
const header = exportOrder.map((key) => csvEscape(headers.find((cell) => cell.dataset.financeColumnKey === key)?.textContent.trim())).join(',');
|
||||
const rows = [];
|
||||
const lastPage = Number(page?.dataset.directoryLastPage || 1);
|
||||
button.disabled = true;
|
||||
const originalLabel = button.textContent;
|
||||
try {
|
||||
if (includeHiddenRows && lastPage > 1) {
|
||||
for (let current = 1; current <= lastPage; current += 1) {
|
||||
button.textContent = `导出中 ${current}/${lastPage}`;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('page', String(current));
|
||||
const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
if (!response.ok) throw new Error(`第 ${current} 页读取失败`);
|
||||
const parsed = new DOMParser().parseFromString(await response.text(), 'text/html');
|
||||
parsed.querySelectorAll('.directory-table tbody tr:not(.is-empty)').forEach((row) => {
|
||||
const cells = [...row.children];
|
||||
const map = new Map(cells.map((cell, index) => [cell.dataset.columnKey || headers[index]?.dataset.financeColumnKey, cell]));
|
||||
if (search?.value && !row.textContent.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase())) return;
|
||||
rows.push(exportOrder.map((key) => csvEscape(cellText(map.get(key)))).join(','));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
tableRows(table, includeHiddenRows, search?.value).forEach((row) => rows.push(orderedCells(row, exportOrder, true).map((cell) => csvEscape(cellText(cell))).join(',')));
|
||||
}
|
||||
downloadCsv(`${page?.dataset.directoryPage || 'directory'}-${includeHiddenRows ? '全部' : '当前页'}-${allColumns ? '全部字段' : '显示列'}.csv`, [header, ...rows]);
|
||||
} catch (error) {
|
||||
window.alert(error.message || '导出失败,请稍后重试');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = originalLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function exportServer(table, button, allColumns) {
|
||||
const headers = leafHeaders(table);
|
||||
const columns = (allColumns ? headers : headers.filter((header) => !header.classList.contains('is-column-hidden')))
|
||||
.map((header) => header.dataset.exportField)
|
||||
.filter(Boolean);
|
||||
if (!columns.length) {
|
||||
window.alert('请至少保留一个可导出的字段。');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(table.dataset.financeExportBase, window.location.origin);
|
||||
url.searchParams.set('columns', columns.join(','));
|
||||
try {
|
||||
const query = JSON.parse(table.dataset.financeExportQuery || '{}');
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined && String(value) !== '') url.searchParams.set(key, String(value));
|
||||
});
|
||||
} catch (_) {
|
||||
// Keep the export usable when an older Vue bundle has no query metadata.
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
const link = document.createElement('a');
|
||||
link.href = url.toString();
|
||||
link.download = '';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => { button.disabled = false; }, 500);
|
||||
}
|
||||
|
||||
function initialise(table) {
|
||||
if (!table || table.hasAttribute('data-finance-table-native') || tableNames.has(table)) return;
|
||||
const prepared = prepareColumns(table);
|
||||
if (!prepared.headers.length) return;
|
||||
tableNames.set(table, true);
|
||||
ensureControls(table, prepared.headers);
|
||||
}
|
||||
|
||||
function scan() { document.querySelectorAll(selector).forEach(initialise); }
|
||||
scan();
|
||||
document.addEventListener('DOMContentLoaded', scan, { once: true });
|
||||
const observer = new MutationObserver(() => scan());
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
window.setTimeout(scan, 0);
|
||||
window.setTimeout(scan, 300);
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const serverButton = event.target.closest('[data-finance-server-export]');
|
||||
if (serverButton) {
|
||||
const table = serverButton.closest('.tb-panel, .ab-panel, .finance-table-scroll-owner, .tb-scroll, .ab-scroll')?.querySelector('table[data-finance-export-base]');
|
||||
if (table) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
exportServer(table, serverButton, serverButton.dataset.financeServerExport === 'all');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const button = event.target.closest('[data-directory-export], [data-directory-export-all], [data-finance-export-all-fields]');
|
||||
if (!button || !button.closest('[data-directory-page]')) return;
|
||||
const table = button.closest('[data-directory-page]')?.querySelector('.directory-table');
|
||||
if (!table) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
exportDirectory(table, button, button.hasAttribute('data-directory-export-all'), button.hasAttribute('data-finance-export-all-fields'));
|
||||
}, true);
|
||||
})();
|
||||
</script>
|
||||
@endonce
|
||||
@ -9,6 +9,11 @@
|
||||
'initialized' => false,
|
||||
'last_succeeded_at' => null,
|
||||
];
|
||||
$productCatalogWorkPlatformPriceSyncStatus = $content['productCatalogWorkPlatformPriceSyncStatus'] ?? [
|
||||
'code' => 'unavailable',
|
||||
'initialized' => false,
|
||||
'last_succeeded_at' => null,
|
||||
];
|
||||
$hasFilters = collect($productCatalogFilters)->contains(
|
||||
static fn ($value) => $value !== null && $value !== ''
|
||||
);
|
||||
@ -220,7 +225,7 @@
|
||||
|
||||
.product-catalog-table {
|
||||
width: 100%;
|
||||
min-width: 2120px;
|
||||
min-width: 2320px;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78125rem;
|
||||
}
|
||||
@ -515,6 +520,25 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (!($productCatalogWorkPlatformPriceSyncStatus['initialized'] ?? false) && !$productCatalogError)
|
||||
<div
|
||||
class="product-catalog-notice"
|
||||
role="status"
|
||||
data-work-platform-price-sync-state="{{ $productCatalogWorkPlatformPriceSyncStatus['code'] ?? 'unavailable' }}"
|
||||
>
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">schedule</span>
|
||||
<span>
|
||||
@if (($productCatalogWorkPlatformPriceSyncStatus['code'] ?? '') === 'initializing')
|
||||
工作平台价格正在执行首次同步,完成前当前仅显示普通商品采购价。
|
||||
@elseif (($productCatalogWorkPlatformPriceSyncStatus['code'] ?? '') === 'initial_failed')
|
||||
工作平台价格首次同步失败,当前仅显示普通商品采购价。
|
||||
@else
|
||||
工作平台价格尚未完成首次同步,当前仅显示普通商品采购价。
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="product-catalog-table-wrap">
|
||||
<table class="product-catalog-table" aria-label="普通商品资料">
|
||||
<thead>
|
||||
@ -531,6 +555,8 @@
|
||||
<th class="is-numeric">销售价</th>
|
||||
<th class="is-numeric">成本价</th>
|
||||
<th class="is-numeric">普通商品采购价</th>
|
||||
<th class="is-numeric">工作平台价格</th>
|
||||
<th>工作平台价格生效日期</th>
|
||||
<th class="is-numeric">1688代发单价</th>
|
||||
<th>数据来源</th>
|
||||
<th class="is-numeric">市场价</th>
|
||||
@ -566,6 +592,8 @@
|
||||
<td class="is-numeric">{{ isset($row->{$priceField}) && $row->{$priceField} !== null ? number_format((float) $row->{$priceField}, 2) : '-' }}</td>
|
||||
@endforeach
|
||||
<td class="is-numeric">{{ isset($row->purchase_price) && $row->purchase_price !== null ? number_format((float) $row->purchase_price, 2) : '-' }}</td>
|
||||
<td class="is-numeric" title="{{ $row->work_platform_price_effect_time ?? '' }}">{{ isset($row->work_platform_price) && $row->work_platform_price !== null ? number_format((float) $row->work_platform_price, 2) : '-' }}</td>
|
||||
<td class="is-code">{{ !empty($row->work_platform_price_effect_time) ? \Illuminate\Support\Carbon::parse($row->work_platform_price_effect_time)->format('Y-m-d') : '-' }}</td>
|
||||
<td class="is-numeric" title="{{ $row->purchase_order_finished_at ?? '' }}">{{ isset($row->purchase_order_unit_price) && $row->purchase_order_unit_price !== null ? number_format((float) $row->purchase_order_unit_price, 2) : '-' }}</td>
|
||||
<td class="product-catalog-source-list">
|
||||
@forelse ($row->data_sources ?? [] as $source)
|
||||
@ -589,7 +617,7 @@
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="18" class="product-catalog-empty">
|
||||
<td colspan="20" class="product-catalog-empty">
|
||||
<span class="material-icons-outlined text-3xl block mb-2" aria-hidden="true">inventory_2</span>
|
||||
@if ($productCatalogError)
|
||||
数据暂时不可用。
|
||||
|
||||
@ -1071,6 +1071,8 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@include('business.pages.partials.finance-table-interactions')
|
||||
|
||||
@if ($isMarketingAccountBillsPage)
|
||||
@push('scripts_start')
|
||||
<script src="{{ asset('public/js/business/marketing-account-bills.min.js') }}?id={{ md5_file(public_path('js/business/marketing-account-bills.min.js')) }}"></script>
|
||||
|
||||
@ -25,6 +25,8 @@ Route::get('ecommerce-finance/alipay-bills/{bill}', [AlipayBills::class, 'show']
|
||||
|
||||
Route::get('ecommerce-finance/account-bills/type/{bill_type}', [AccountBills::class, 'index'])
|
||||
->name('ecommerce-finance.account-bills.index');
|
||||
Route::get('ecommerce-finance/account-bills/type/{bill_type}/export', [AccountBills::class, 'export'])
|
||||
->name('ecommerce-finance.account-bills.export');
|
||||
Route::get('ecommerce-finance/account-bills/{bill}', [AccountBills::class, 'show'])
|
||||
->name('ecommerce-finance.account-bills.show');
|
||||
|
||||
|
||||
@ -37,8 +37,18 @@ class CourierSurchargePageTest extends FeatureTestCase
|
||||
->assertSeeText('对账人')
|
||||
->assertSeeText('张会计')
|
||||
->assertSeeText('附加费类型(可多选)')
|
||||
->assertSeeText('列设置')
|
||||
->assertSeeText('导出当前列')
|
||||
->assertSeeText('导出全部字段')
|
||||
->assertSeeText('规则编号与操作列固定显示')
|
||||
->assertSee('data-column-storage-key="caiwu:table-columns:', false)
|
||||
->assertSee('data-surcharge-table', false)
|
||||
->assertSee('data-column-check', false)
|
||||
->assertSee('data-surcharge-export-current', false)
|
||||
->assertSee('data-surcharge-export-all', false)
|
||||
->assertSee('data-column-reset', false)
|
||||
->assertSee('name="types[]"', false)
|
||||
->assertSeeInOrder(['<th>快递名称</th>', '<th>快递归属</th>', '<th>对账人</th>'], false)
|
||||
->assertSeeInOrder(['data-column-key="courier"', 'data-column-key="scope"', 'data-column-key="reconciler"'], false)
|
||||
->assertSeeText('曹县德邦')
|
||||
->assertSeeText('超长费')
|
||||
->assertSeeText('按尺寸/最长边')
|
||||
|
||||
@ -7,6 +7,7 @@ use Mockery;
|
||||
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\View\OrderDataPageData;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
@ -27,25 +28,19 @@ class PagesTest extends FeatureTestCase
|
||||
Mockery::type('int'),
|
||||
)->andReturn([
|
||||
'data' => [[
|
||||
'waybill' => 'FIXTURE-WAYBILL',
|
||||
'expressname' => '测试快递',
|
||||
'courier_company_name' => '测试快递',
|
||||
'month' => '2026-08',
|
||||
'status' => '已确认',
|
||||
'reconciliation_difference' => 1.25,
|
||||
'confirmed_amount' => 20.0,
|
||||
'payable_amount' => 20.0,
|
||||
'actual_payment' => 18.75,
|
||||
'advance_payment' => 0.0,
|
||||
'compensation_amount' => 0.0,
|
||||
'other_replenishment' => 0.0,
|
||||
'corporate_payment_amount' => 18.75,
|
||||
'personal_payment_amount' => 0.0,
|
||||
'create_time' => '2026-08-01 09:00:00',
|
||||
'modify_time' => '2026-08-02 09:00:00',
|
||||
'detail_id' => 1,
|
||||
'waybill' => 'FIXTURE-WAYBILL', 'product_id' => 'ITEM-1',
|
||||
'send_time' => '2026-08-01 09:00:00', 'transaction_order_no' => 'SO-1',
|
||||
'province' => '江苏省', 'city' => '常州市', 'district' => '武进区', 'barcode' => 'SKU-1',
|
||||
'express_name' => '测试快递', 'express_name_2' => '测试快递', 'quantity' => 1,
|
||||
'product_name' => '测试商品', 'platform' => '淘宝天猫', 'shop_name' => '测试店铺',
|
||||
'warehouse' => '测试仓库', 'volume' => 1.2, 'supplier' => '测试供应商',
|
||||
'length' => 1.0, 'width' => 2.0, 'height' => 3.0, 'freight' => 20.0,
|
||||
'weight' => 1.25, 'reconciliation_weight' => 1.25, 'reconciliation_volume' => 1.2,
|
||||
'month' => '2026-08', 'status' => '已发货',
|
||||
]],
|
||||
'total' => 1,
|
||||
'summary' => ['count' => 1, 'payable_amount' => 20.0, 'actual_payment' => 18.75, 'confirmed_amount' => 20.0, 'reconciliation_difference' => 1.25],
|
||||
'summary' => ['count' => 1, 'freight' => 20.0, 'payable_amount' => 20.0, 'actual_payment' => 0.0, 'confirmed_amount' => 0.0, 'reconciliation_difference' => 0.0],
|
||||
'state' => ['status' => 'succeeded'],
|
||||
'sync' => ['code' => 'fresh', 'last_succeeded_at' => '2026-08-02 09:00:00', 'lag_minutes' => 1],
|
||||
]);
|
||||
@ -59,7 +54,12 @@ class PagesTest extends FeatureTestCase
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('FIXTURE-WAYBILL')
|
||||
->assertSeeText('应付金额')
|
||||
->assertSeeText('条形码')
|
||||
->assertSeeText('交易订单号')
|
||||
->assertSeeText('对账体积(cm3)')
|
||||
->assertSeeText('SKU-1')
|
||||
->assertSeeText('测试供应商')
|
||||
->assertSeeText('运费合计')
|
||||
->assertSeeText('20.00')
|
||||
->assertSeeText('数据新鲜')
|
||||
->assertSeeText('共享业务镜像')
|
||||
@ -117,6 +117,94 @@ class PagesTest extends FeatureTestCase
|
||||
->assertSee('public/js/business/pages.min.js', false);
|
||||
}
|
||||
|
||||
public function testDouyinAccountBillsEndpointReadsTheFixedTouTiaoChannel(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
|
||||
$repository = Mockery::mock(StoreReceivingDouyinBillMirrorRepository::class);
|
||||
$repository->shouldReceive('paginate')->once()->with(
|
||||
[], 'occurred_at', 'desc', 1, 10,
|
||||
)->andReturn([
|
||||
'current_page' => 1,
|
||||
'last_page' => 1,
|
||||
'per_page' => 10,
|
||||
'total' => 1,
|
||||
'data' => [[
|
||||
'id' => '9001',
|
||||
'record_code' => '9001',
|
||||
'store_name' => '放心购旗舰店',
|
||||
'income' => 88.50,
|
||||
'expense' => 0.0,
|
||||
'payload' => [
|
||||
'settle_account' => 'TouTiaoFXGZJ',
|
||||
'move_scene' => '头条放心购',
|
||||
],
|
||||
]],
|
||||
'summary' => ['income' => 88.50, 'expense' => 0.0, 'balance' => 88.50, 'count' => 1],
|
||||
'sync' => ['status' => 'succeeded', 'last_succeeded_at' => '2026-09-08 09:00:00'],
|
||||
]);
|
||||
$this->app->instance(StoreReceivingDouyinBillMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->withHeader('X-Requested-With', 'XMLHttpRequest')
|
||||
->get(route('ecommerce-finance.account-bills.index', ['bill_type' => 'douyin']))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.payload.move_scene', '头条放心购')
|
||||
->assertJsonPath('data.0.payload.settle_account', 'TouTiaoFXGZJ')
|
||||
->assertJsonPath('columns.title', '抖音账户')
|
||||
->assertJsonPath('summary.count', 1);
|
||||
}
|
||||
|
||||
public function testDouyinAccountBillsExportUsesSelectedColumnOrder(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
|
||||
$repository = Mockery::mock(StoreReceivingDouyinBillMirrorRepository::class);
|
||||
$repository->shouldReceive('exportRows')->once()->with([], 'occurred_at', 'desc')->andReturn([[
|
||||
'record_code' => '9001',
|
||||
'payload' => [
|
||||
'move_scene' => '头条放心购',
|
||||
'settle_account' => 'TouTiaoFXGZJ',
|
||||
],
|
||||
]]);
|
||||
$this->app->instance(StoreReceivingDouyinBillMirrorRepository::class, $repository);
|
||||
|
||||
$response = $this->loginAs()->get(route('ecommerce-finance.account-bills.export', [
|
||||
'bill_type' => 'douyin',
|
||||
'columns' => 'payload.move_scene,record_code,payload.settle_account',
|
||||
]));
|
||||
|
||||
$response->assertOk()
|
||||
->assertHeader('content-type', 'text/csv; charset=UTF-8')
|
||||
->assertDownload('douyin_account_bills.csv');
|
||||
$csv = $response->streamedContent();
|
||||
$this->assertStringContainsString('动账场景,记录编码,结算账户', $csv);
|
||||
$this->assertStringContainsString('头条放心购,9001,TouTiaoFXGZJ', $csv);
|
||||
}
|
||||
|
||||
public function testDouyinAccountBillsReturnsARetryableResponseWhenMirrorIsNotReady(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
|
||||
$repository = Mockery::mock(StoreReceivingDouyinBillMirrorRepository::class);
|
||||
$repository->shouldReceive('paginate')->once()->andThrow(
|
||||
new \RuntimeException('store_receiving_alipay_bill_mirror_not_ready')
|
||||
);
|
||||
$this->app->instance(StoreReceivingDouyinBillMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->withHeader('X-Requested-With', 'XMLHttpRequest')
|
||||
->get(route('ecommerce-finance.account-bills.index', ['bill_type' => 'douyin']))
|
||||
->assertStatus(503)
|
||||
->assertJsonPath('message', '抖音账户账单共享业务镜像正在首次同步,完成后将自动显示。');
|
||||
}
|
||||
|
||||
public function testTaobaoAlipayBillsExportUsesTheMirrorRepository()
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
@ -128,7 +216,7 @@ class PagesTest extends FeatureTestCase
|
||||
static fn (array $filters): bool => ($filters['store_name'] ?? '') === '天猫-经营店'
|
||||
&& ($filters['start'] ?? '') === '2026-08-01'
|
||||
&& ($filters['end'] ?? '') === '2026-08-31'
|
||||
))->andReturn([[
|
||||
), 'occurred_at', 'desc')->andReturn([[
|
||||
'record_code' => '1', 'store_name' => '天猫-经营店', 'alipay_account' => 'CommonBillTGCPay',
|
||||
'bill_item' => '交易佣金', 'account_order_no' => 'KEY-1', 'business_serial_no' => '1001',
|
||||
'merchant_order_no' => 'P-1', 'business_base_order_no' => 'ORDER-1', 'goods_name' => 'SKU-1',
|
||||
@ -286,7 +374,7 @@ class PagesTest extends FeatureTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testBillOffsetPageRendersRowsSummaryAndExportLink(): void
|
||||
public function testBillOffsetPageUsesAfterSalesRowsAndTheOriginalAccountField(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
@ -371,76 +459,74 @@ class PagesTest extends FeatureTestCase
|
||||
->assertSeeText('订单售后费用数据暂时无法读取,请稍后重试。');
|
||||
}
|
||||
|
||||
public function testBillOffsetExportReturnsCsvForTheFilteredRows(): void
|
||||
public function testBillOffsetExportKeepsUnknownAccountAmountsEmpty(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$filters = [
|
||||
'from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => '9228703', 'shop_name' => '', 'sku_id' => '',
|
||||
'from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => 'ORDER-EMPTY', 'shop_name' => '', 'sku_id' => '',
|
||||
];
|
||||
$orderData = Mockery::mock(OrderDataPageData::class);
|
||||
$orderData->shouldReceive('afterSalesFilters')->once()->andReturn($filters);
|
||||
$orderData->shouldReceive('afterSalesFilters')->once()->with($filters)->andReturn($filters);
|
||||
$orderData->shouldReceive('afterSalesRows')->once()->with($filters, 1, 100)->andReturn([
|
||||
'rows' => [[
|
||||
'record_id' => '3', 'fee_time' => '2026-08-03 10:00:00', 'fee_name' => '平台冲减', 'inout_type' => '收入',
|
||||
'internal_order_no' => '9228703', 'online_order_no' => '5127724585360006527', 'shop_name' => '测试店铺',
|
||||
'shop_code' => 'SHOP-1', 'product_code' => 'SKU-3', 'style_code' => 'STYLE-3', 'shop_product_code' => 'SP-3',
|
||||
'shop_style_code' => 'SI-3', 'account_500112' => -20,
|
||||
'reversal_order_amount' => 999,
|
||||
'internal_order_no' => 'ORDER-EMPTY', 'account_500112' => null, 'reversal_order_amount' => 987.65,
|
||||
]],
|
||||
'has_more' => false, 'status' => ['X-Caiwu-Data-Status' => 'direct'],
|
||||
]);
|
||||
$this->app->instance(OrderDataPageData::class, $orderData);
|
||||
|
||||
$response = $this->loginAs()->get(route('business.bill-offset.export', [
|
||||
'from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => '9228703',
|
||||
]));
|
||||
|
||||
$response->assertOk()
|
||||
->assertHeader('Content-Type', 'text/csv; charset=UTF-8');
|
||||
$this->assertStringContainsString('.csv', (string) $response->headers->get('Content-Disposition'));
|
||||
$csv = $response->streamedContent();
|
||||
$this->assertStringContainsString('500112冲减收入', $csv);
|
||||
$this->assertStringContainsString('测试店铺', $csv);
|
||||
$this->assertStringContainsString('-20.00', $csv);
|
||||
$csv = $this->loginAs()->get(route('business.bill-offset.export', $filters))->streamedContent();
|
||||
$this->assertStringNotContainsString('987.65', $csv);
|
||||
$lines = preg_split('/\r?\n/', trim($csv));
|
||||
$this->assertSame('', str_getcsv($lines[1])[8]);
|
||||
}
|
||||
|
||||
public function testBillOffsetExportIncludesAllPagesForTheSameFilters(): void
|
||||
public function testBillOffsetShowsTheOriginalDataErrorAndBlocksAnEmptyExport(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
$filters = [
|
||||
'from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => '', 'shop_name' => '', 'sku_id' => '',
|
||||
];
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$filters = ['from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => '', 'shop_name' => '', 'sku_id' => ''];
|
||||
$orderData = Mockery::mock(OrderDataPageData::class);
|
||||
$orderData->shouldReceive('afterSalesFilters')->once()->andReturn($filters);
|
||||
$orderData->shouldReceive('afterSalesFilters')->twice()->andReturn($filters);
|
||||
$orderData->shouldReceive('afterSalesRows')->twice()->andThrow(new \RuntimeException('database unavailable'));
|
||||
$this->app->instance(OrderDataPageData::class, $orderData);
|
||||
$this->loginAs()->get(route('business.pages.show', ['section' => 'order-data', 'page' => 'bill-offset']))
|
||||
->assertOk()->assertSeeText('订单售后费用数据暂时无法读取,请稍后重试。');
|
||||
$this->withExceptionHandling()->get(route('business.bill-offset.export'))->assertStatus(503);
|
||||
}
|
||||
|
||||
public function testBillOffsetExportIncludesAllPagesFromOriginalDataWithTheSameFilters(): void
|
||||
{
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$filters = ['from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => 'ORDER', 'shop_name' => '', 'sku_id' => ''];
|
||||
$orderData = Mockery::mock(OrderDataPageData::class);
|
||||
$orderData->shouldReceive('afterSalesFilters')->once()->with($filters)->andReturn($filters);
|
||||
$orderData->shouldReceive('afterSalesRows')->once()->with($filters, 1, 100)->andReturn([
|
||||
'rows' => [[
|
||||
'internal_order_no' => 'ORDER-1', 'online_order_no' => 'ONLINE-1', 'shop_name' => '店铺一',
|
||||
'shop_code' => 'SHOP-1', 'product_code' => 'SKU-1', 'style_code' => 'STYLE-1',
|
||||
'shop_product_code' => 'SP-1', 'shop_style_code' => 'SI-1', 'account_500112' => -20,
|
||||
'internal_order_no' => 'ORDER-1', 'online_order_no' => '001234567890123456789',
|
||||
'shop_name' => '测试店铺', 'account_500112' => -20,
|
||||
]],
|
||||
'has_more' => true, 'status' => ['X-Caiwu-Data-Status' => 'direct'],
|
||||
]);
|
||||
$orderData->shouldReceive('afterSalesRows')->once()->with($filters, 2, 100)->andReturn([
|
||||
'rows' => [[
|
||||
'internal_order_no' => 'ORDER-101', 'online_order_no' => 'ONLINE-101', 'shop_name' => '店铺一',
|
||||
'shop_code' => 'SHOP-1', 'product_code' => 'SKU-101', 'style_code' => 'STYLE-101',
|
||||
'shop_product_code' => 'SP-101', 'shop_style_code' => 'SI-101', 'account_500112' => 18.25,
|
||||
'internal_order_no' => 'ORDER-101', 'account_500112' => null, 'reversal_order_amount' => 987.65,
|
||||
]],
|
||||
'has_more' => false, 'status' => ['X-Caiwu-Data-Status' => 'direct'],
|
||||
]);
|
||||
$this->app->instance(OrderDataPageData::class, $orderData);
|
||||
|
||||
$csv = $this->loginAs()->get(route('business.bill-offset.export', [
|
||||
'from' => '2026-08-01', 'to' => '2026-08-31',
|
||||
]))->streamedContent();
|
||||
|
||||
$response = $this->loginAs()->get(route('business.bill-offset.export', $filters));
|
||||
$response->assertOk()->assertHeader('Content-Type', 'text/csv; charset=UTF-8');
|
||||
$csv = $response->streamedContent();
|
||||
$this->assertStringContainsString('500112冲减收入', $csv);
|
||||
$this->assertStringContainsString('测试店铺', $csv);
|
||||
$this->assertStringContainsString('-20.00', $csv);
|
||||
$this->assertStringContainsString("'001234567890123456789", $csv);
|
||||
$this->assertStringContainsString('ORDER-1', $csv);
|
||||
$this->assertStringContainsString('ORDER-101', $csv);
|
||||
$this->assertSame(3, substr_count($csv, "\n"));
|
||||
$this->assertStringNotContainsString('987.65', $csv);
|
||||
$lines = preg_split('/\r?\n/', trim($csv));
|
||||
$this->assertCount(3, $lines);
|
||||
$this->assertSame('', str_getcsv($lines[2])[8]);
|
||||
}
|
||||
|
||||
public function testFinancialAfterSalesPageRendersMirroredJushuitanRefunds(): void
|
||||
|
||||
@ -58,10 +58,12 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
'sale_price' => 899,
|
||||
'cost_price' => 500,
|
||||
'purchase_price' => 480,
|
||||
'work_platform_price' => 475,
|
||||
'work_platform_price_effect_time' => '2026-08-11 00:00:00',
|
||||
'purchase_order_unit_price' => 65,
|
||||
'purchase_order_finished_at' => '2026-08-20 11:03:05',
|
||||
'purchase_order_id' => 827380,
|
||||
'data_sources' => ['商品及库存管理(普通商品资料)', '1688代发单价'],
|
||||
'data_sources' => ['商品及库存管理(普通商品资料)', '工作平台价格', '1688代发单价'],
|
||||
'market_price' => 1099,
|
||||
'brand' => '北木',
|
||||
'category' => '住宅家具',
|
||||
@ -76,6 +78,11 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
'initialized' => true,
|
||||
'last_succeeded_at' => '2026-08-21 12:00:00',
|
||||
],
|
||||
'work_platform_price_sync_status' => [
|
||||
'code' => 'ready',
|
||||
'initialized' => true,
|
||||
'last_succeeded_at' => '2026-09-01 03:02:00',
|
||||
],
|
||||
]);
|
||||
$this->app->instance(ProductCatalogMirrorRepository::class, $repository);
|
||||
|
||||
@ -105,6 +112,9 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
->assertSeeText('岩板餐桌')
|
||||
->assertSeeText('899.00')
|
||||
->assertSeeText('普通商品采购价')
|
||||
->assertSeeText('工作平台价格')
|
||||
->assertSeeText('475.00')
|
||||
->assertSeeText('2026-08-11')
|
||||
->assertSeeText('1688代发单价')
|
||||
->assertSeeText('数据来源')
|
||||
->assertSeeText('商品及库存管理(普通商品资料)')
|
||||
@ -112,6 +122,7 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
->assertSeeText('65.00')
|
||||
->assertSeeText('导出')
|
||||
->assertDontSeeText('1688代发单价尚未完成首次同步')
|
||||
->assertDontSeeText('工作平台价格尚未完成首次同步')
|
||||
->assertSeeText('正常同步')
|
||||
->assertDontSee('pic_url');
|
||||
}
|
||||
@ -146,8 +157,10 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
'sale_price' => 899,
|
||||
'cost_price' => 500,
|
||||
'purchase_price' => 480,
|
||||
'work_platform_price' => 475,
|
||||
'work_platform_price_effect_time' => '2026-08-11 00:00:00',
|
||||
'purchase_order_unit_price' => 65,
|
||||
'data_sources' => ['商品及库存管理(普通商品资料)', '1688代发单价'],
|
||||
'data_sources' => ['商品及库存管理(普通商品资料)', '工作平台价格', '1688代发单价'],
|
||||
'market_price' => 1099,
|
||||
'enabled' => 1,
|
||||
'stock_disabled' => 0,
|
||||
@ -168,7 +181,9 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
$content = $response->streamedContent();
|
||||
$this->assertStringStartsWith(chr(0xEF) . chr(0xBB) . chr(0xBF), $content);
|
||||
$this->assertStringContainsString('数据来源', $content);
|
||||
$this->assertStringContainsString('商品及库存管理(普通商品资料);1688代发单价', $content);
|
||||
$this->assertStringContainsString('工作平台价格', $content);
|
||||
$this->assertStringContainsString('475', $content);
|
||||
$this->assertStringContainsString('商品及库存管理(普通商品资料);工作平台价格;1688代发单价', $content);
|
||||
$this->assertStringContainsString('SKU-001', $content);
|
||||
}
|
||||
|
||||
|
||||
@ -1,123 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\BillOffsetMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BillOffsetMirrorRepositoryTest extends TestCase
|
||||
{
|
||||
private BillOffsetMirrorRepository $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config([
|
||||
'database.connections.business_mirror' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''],
|
||||
'upstream_mirrors.connection' => 'business_mirror',
|
||||
'upstream_mirrors.datasets' => [BillOffsetMirrorRepository::DATASET => [
|
||||
'source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_originalBillInfo',
|
||||
'target' => 'ops_jst_original_bill_info', 'keys' => ['Id'],
|
||||
]],
|
||||
]);
|
||||
DB::purge('business_mirror');
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->dateTime('initial_completed_at')->nullable();
|
||||
$table->string('status')->nullable();
|
||||
$table->dateTime('last_succeeded_at')->nullable();
|
||||
});
|
||||
$schema->create('ops_jst_original_bill_info', function (Blueprint $table): void {
|
||||
$table->integer('Id');
|
||||
$table->dateTime('fee_time')->nullable();
|
||||
$table->string('fee_name')->nullable();
|
||||
$table->string('inout_type')->nullable();
|
||||
$table->string('so_id')->nullable();
|
||||
$table->string('p_auto_id')->nullable();
|
||||
$table->string('sku_id')->nullable();
|
||||
$table->string('shop_sku_id')->nullable();
|
||||
$table->string('shop_i_id')->nullable();
|
||||
$table->decimal('amount', 18, 2)->nullable();
|
||||
});
|
||||
$schema->create('ops_jst_orders', function (Blueprint $table): void {
|
||||
$table->integer('o_id'); $table->string('so_id')->nullable(); $table->string('shop_name')->nullable(); $table->string('shop_id')->nullable();
|
||||
});
|
||||
$schema->create('ops_jst_order_item', function (Blueprint $table): void {
|
||||
$table->integer('o_id'); $table->string('sku_id')->nullable(); $table->string('i_id')->nullable(); $table->string('shop_sku_id')->nullable(); $table->string('shop_i_id')->nullable();
|
||||
});
|
||||
$this->repository = new BillOffsetMirrorRepository(new BusinessMirrorDatasetRegistry(), new GenericBusinessMirrorRepository(app('db')));
|
||||
}
|
||||
|
||||
public function test_it_filters_joins_orders_and_calculates_summary(): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
|
||||
'dataset_key' => BillOffsetMirrorRepository::DATASET,
|
||||
'initial_completed_at' => '2026-09-08 09:00:00', 'status' => 'succeeded', 'last_succeeded_at' => '2026-09-08 09:00:00',
|
||||
]);
|
||||
DB::connection('business_mirror')->table('ops_jst_original_bill_info')->insert([
|
||||
['Id' => 1, 'fee_time' => '2026-08-01 10:00:00', 'fee_name' => '500112冲减收入', 'inout_type' => '收入', 'so_id' => 'SO-1', 'sku_id' => 'SKU-1', 'amount' => -20],
|
||||
['Id' => 2, 'fee_time' => '2026-08-02 10:00:00', 'fee_name' => '500112冲减收入', 'inout_type' => '收入', 'so_id' => 'SO-2', 'sku_id' => 'SKU-2', 'amount' => 18.25],
|
||||
['Id' => 3, 'fee_time' => '2026-08-03 10:00:00', 'fee_name' => '冲减收入', 'inout_type' => '收入', 'so_id' => 'SO-3', 'sku_id' => 'SKU-3', 'amount' => 7],
|
||||
['Id' => 4, 'fee_time' => '2026-08-01 10:00:00', 'fee_name' => '订单收入', 'inout_type' => '收入', 'so_id' => 'SO-1', 'sku_id' => 'SKU-1', 'amount' => 100],
|
||||
]);
|
||||
DB::connection('business_mirror')->table('ops_jst_orders')->insert([
|
||||
['o_id' => 1001, 'so_id' => 'SO-1', 'shop_name' => '测试店铺', 'shop_id' => 'SHOP-1'],
|
||||
['o_id' => 1002, 'so_id' => 'SO-2', 'shop_name' => '其他店铺', 'shop_id' => 'SHOP-2'],
|
||||
['o_id' => 1003, 'so_id' => 'SO-3', 'shop_name' => '历史店铺', 'shop_id' => 'SHOP-3'],
|
||||
]);
|
||||
DB::connection('business_mirror')->table('ops_jst_order_item')->insert([
|
||||
['o_id' => 1001, 'sku_id' => 'SKU-1', 'i_id' => 'STYLE-1', 'shop_sku_id' => 'SP-1', 'shop_i_id' => 'SI-1'],
|
||||
]);
|
||||
|
||||
$result = $this->repository->paginate(['from' => '2026-08-01', 'to' => '2026-08-01', 'shop_name' => '测试店铺', 'sku_id' => 'SKU-1'], 1, 10);
|
||||
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame('1001', (string) $result['data'][0]['internal_order_no']);
|
||||
$this->assertSame('测试店铺', $result['data'][0]['shop_name']);
|
||||
$this->assertSame('STYLE-1', $result['data'][0]['style_code']);
|
||||
$this->assertSame(-20.0, $result['summary']['amount']);
|
||||
$this->assertSame('succeeded', $result['sync']['status']);
|
||||
|
||||
$all = $this->repository->paginate([], 1, 10);
|
||||
$this->assertSame(3, $all['total']);
|
||||
$this->assertSame(5.25, $all['summary']['amount']);
|
||||
$this->assertSame('SO-3', $all['data'][0]['online_order_no']);
|
||||
}
|
||||
|
||||
public function test_it_rejects_unready_mirror(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('bill_offset_mirror_not_ready');
|
||||
$this->repository->paginate([], 1, 10);
|
||||
}
|
||||
|
||||
public function test_it_reads_bill_rows_when_order_mirror_tables_are_not_available(): void
|
||||
{
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->dropIfExists('ops_jst_order_item');
|
||||
$schema->dropIfExists('ops_jst_orders');
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
|
||||
'dataset_key' => BillOffsetMirrorRepository::DATASET,
|
||||
'initial_completed_at' => '2026-09-08 09:00:00', 'status' => 'succeeded', 'last_succeeded_at' => '2026-09-08 09:00:00',
|
||||
]);
|
||||
DB::connection('business_mirror')->table('ops_jst_original_bill_info')->insert([
|
||||
'Id' => 3, 'fee_time' => '2026-08-03 10:00:00', 'fee_name' => '500112冲减收入', 'inout_type' => '收入',
|
||||
'so_id' => 'SO-3', 'sku_id' => 'SKU-3', 'amount' => 12.5,
|
||||
]);
|
||||
|
||||
$result = $this->repository->paginate([], 1, 10);
|
||||
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame('SO-3', $result['data'][0]['internal_order_no']);
|
||||
$this->assertSame('SKU-3', $result['data'][0]['product_code']);
|
||||
$this->assertSame(12.5, $result['summary']['amount']);
|
||||
$this->assertSame(0, $this->repository->paginate(['shop_name' => '测试店铺'], 1, 10)['total']);
|
||||
}
|
||||
}
|
||||
@ -75,11 +75,20 @@ class BusinessDataPlacementTest extends TestCase
|
||||
$upstream = require base_path('config/upstream_mirrors.php');
|
||||
$businessData = require base_path('config/business_data.php');
|
||||
|
||||
$this->assertCount(52, $upstream['datasets']);
|
||||
$this->assertCount(56, $upstream['datasets']);
|
||||
$upstreamKeys = array_keys($upstream['datasets']);
|
||||
$policyKeys = array_keys($businessData['datasets']);
|
||||
sort($upstreamKeys);
|
||||
sort($policyKeys);
|
||||
$this->assertSame($upstreamKeys, $policyKeys);
|
||||
}
|
||||
|
||||
public function test_original_bills_keep_refreshing_for_the_current_alipay_reader(): void
|
||||
{
|
||||
$businessData = require base_path('config/business_data.php');
|
||||
config(['business_data.datasets' => $businessData['datasets']]);
|
||||
$placement = new BusinessDataPlacement(app('db'));
|
||||
$this->assertFalse($placement->isActive('operations_replica.rpt.jst_originalBillInfo', 'clickhouse'));
|
||||
$this->assertTrue($placement->shouldSchedule('operations_replica.rpt.jst_originalBillInfo'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,6 +65,7 @@ class BusinessMirrorSynchronizerTest extends TestCase
|
||||
$table->dateTime('last_succeeded_at')->nullable();
|
||||
$table->unsignedBigInteger('last_upserted_rows')->default(0);
|
||||
$table->unsignedBigInteger('mirror_row_count')->default(0);
|
||||
$table->boolean('mirror_row_count_is_exact')->default(false);
|
||||
$table->string('last_error_code')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
@ -317,6 +318,78 @@ class BusinessMirrorSynchronizerTest extends TestCase
|
||||
$this->assertContains(['created', 'id'], $indexes);
|
||||
}
|
||||
|
||||
public function test_declared_255_character_lookup_preserves_the_value_and_builds_an_index(): void
|
||||
{
|
||||
DB::table('upstream_schema_columns')->where('source_key', 'test')->where('column_name', 'displayName')
|
||||
->update(['max_length' => 255]);
|
||||
$definitions = config('upstream_mirrors.datasets');
|
||||
$definitions['test.dbo.records']['indexes'] = [['displayName', 'Id']];
|
||||
config(['upstream_mirrors.datasets' => $definitions]);
|
||||
$dataset = (new BusinessMirrorDatasetRegistry())->get('test.dbo.records');
|
||||
(new BusinessMirrorSchemaManager())->ensure($dataset);
|
||||
$indexes = collect(Schema::connection('business_mirror')->getIndexes($dataset->targetTable()))
|
||||
->pluck('columns')->all();
|
||||
$this->assertContains(['displayName', 'Id'], $indexes);
|
||||
$value = str_repeat('x', 255);
|
||||
$this->query->batches = [[['Id' => 1, 'updateTime' => '2026-08-14 10:00:00', 'displayName' => $value]]];
|
||||
$this->synchronizer->sync('test.dbo.records', true);
|
||||
$this->assertSame($value, DB::connection('business_mirror')->table($dataset->targetTable())->value('displayName'));
|
||||
}
|
||||
|
||||
public function test_incremental_batches_keep_exact_counts_without_counting_updates_as_inserts(): void
|
||||
{
|
||||
$this->query->batches = [[
|
||||
['Id' => 1, 'updateTime' => '2026-08-14 10:00:00', 'displayName' => 'one'],
|
||||
['Id' => 2, 'updateTime' => '2026-08-14 10:00:00', 'displayName' => 'two'],
|
||||
]];
|
||||
$this->synchronizer->sync('test.dbo.records', true);
|
||||
$db = DB::connection('business_mirror');
|
||||
$state = fn () => $db->table('business_mirror_table_states')->where('dataset_key', 'test.dbo.records')->first();
|
||||
$this->assertTrue((bool) $state()->mirror_row_count_is_exact);
|
||||
$this->query->batches = [[
|
||||
['Id' => 1, 'updateTime' => '2026-08-15 10:00:00', 'displayName' => 'updated'],
|
||||
['Id' => 3, 'updateTime' => '2026-08-15 10:00:00', 'displayName' => 'new'],
|
||||
]];
|
||||
$this->synchronizer->sync('test.dbo.records', false, 2, 1);
|
||||
$this->assertSame('running', $state()->status);
|
||||
$this->assertSame(3, (int) $state()->mirror_row_count);
|
||||
$this->assertTrue((bool) $state()->mirror_row_count_is_exact);
|
||||
$this->assertSame(3, $db->table('test_records')->count());
|
||||
$this->query->batches = [[
|
||||
['Id' => 4, 'updateTime' => '2026-08-15 10:00:00', 'displayName' => 'rollback-a'],
|
||||
['Id' => 5, 'updateTime' => '2026-08-15 10:00:00', 'displayName' => 'rollback-b'],
|
||||
]];
|
||||
$db->beginTransaction();
|
||||
$this->synchronizer->sync('test.dbo.records', false, 2, 1);
|
||||
$this->assertSame(5, (int) $state()->mirror_row_count);
|
||||
$db->rollBack();
|
||||
$this->assertSame(3, (int) $state()->mirror_row_count);
|
||||
$this->assertSame(3, $db->table('test_records')->count());
|
||||
$db->enableQueryLog();
|
||||
$db->flushQueryLog();
|
||||
$this->query->batches = [[]];
|
||||
$this->synchronizer->sync('test.dbo.records');
|
||||
$this->assertSame(3, (int) $state()->mirror_row_count);
|
||||
$this->assertCount(0, array_filter($db->getQueryLog(), static fn ($q) => str_contains(strtolower($q['query']), 'count(*)') && str_contains($q['query'], 'test_records')));
|
||||
$db->disableQueryLog();
|
||||
}
|
||||
|
||||
public function test_legacy_running_count_is_not_trusted_until_the_run_completes(): void
|
||||
{
|
||||
$this->query->batches = [[['Id' => 1, 'updateTime' => '2026-08-14 10:00:00', 'displayName' => 'one']]];
|
||||
$this->synchronizer->sync('test.dbo.records', true);
|
||||
$db = DB::connection('business_mirror');
|
||||
$query = $db->table('business_mirror_table_states')->where('dataset_key', 'test.dbo.records');
|
||||
$query->update(['status' => 'running', 'run_mode' => 'incremental', 'cursor' => '{"Id":1}', 'mirror_row_count' => 999, 'mirror_row_count_is_exact' => false]);
|
||||
$this->query->batches = [[['Id' => 2, 'updateTime' => '2026-08-15 10:00:00', 'displayName' => 'two']]];
|
||||
$this->synchronizer->sync('test.dbo.records', false, 1, 1);
|
||||
$this->assertFalse((bool) $query->first()->mirror_row_count_is_exact);
|
||||
$this->query->batches = [[]];
|
||||
$this->synchronizer->sync('test.dbo.records');
|
||||
$this->assertSame(2, (int) $query->first()->mirror_row_count);
|
||||
$this->assertTrue((bool) $query->first()->mirror_row_count_is_exact);
|
||||
}
|
||||
|
||||
public function test_empty_full_read_does_not_delete_previous_mirror_rows(): void
|
||||
{
|
||||
$this->query->batches = [[
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CourierDetailPaginationMigrationTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config([
|
||||
'system_database.mode' => 'connected',
|
||||
'upstream_mirrors.connection' => 'courier_migration_test',
|
||||
'database.connections.courier_migration_test' => [
|
||||
'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '',
|
||||
],
|
||||
]);
|
||||
DB::purge('courier_migration_test');
|
||||
Schema::connection('courier_migration_test')->create('js_jst_order_express_details', function (Blueprint $table): void {
|
||||
$table->integer('id');
|
||||
$table->longText('logistic_id')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function test_migration_and_rollback_preserve_waybills_and_nulls(): void
|
||||
{
|
||||
$table = DB::connection('courier_migration_test')->table('js_jst_order_express_details');
|
||||
$rows = [['id' => 1, 'logistic_id' => '001234567890123456789'], ['id' => 2, 'logistic_id' => null]];
|
||||
$table->insert($rows);
|
||||
$migration = require base_path('database/migrations/2026_09_09_160000_index_courier_detail_pagination.php');
|
||||
$migration->up();
|
||||
$migration->up();
|
||||
$this->assertTrue(Schema::connection('courier_migration_test')->hasIndex('js_jst_order_express_details', 'idx_courier_detail_id_waybill'));
|
||||
$this->assertSame($rows, $table->orderBy('id')->get()->map(fn ($row) => (array) $row)->all());
|
||||
$migration->down();
|
||||
$this->assertFalse(Schema::connection('courier_migration_test')->hasIndex('js_jst_order_express_details', 'idx_courier_detail_id_waybill'));
|
||||
$this->assertSame($rows, $table->orderBy('id')->get()->map(fn ($row) => (array) $row)->all());
|
||||
}
|
||||
|
||||
public function test_oversize_waybill_stops_migration_without_truncation(): void
|
||||
{
|
||||
$value = str_repeat('A', 101);
|
||||
$table = DB::connection('courier_migration_test')->table('js_jst_order_express_details');
|
||||
$table->insert(['id' => 1, 'logistic_id' => $value]);
|
||||
$migration = require base_path('database/migrations/2026_09_09_160000_index_courier_detail_pagination.php');
|
||||
try {
|
||||
$migration->up();
|
||||
$this->fail('Oversize source data must stop migration.');
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->assertSame($value, $table->value('logistic_id'));
|
||||
$this->assertFalse(Schema::connection('courier_migration_test')->hasIndex('js_jst_order_express_details', 'idx_courier_detail_id_waybill'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -21,19 +21,21 @@ class CourierFreightReconciliationMirrorRepositoryTest extends TestCase
|
||||
|
||||
config([
|
||||
'database.connections.business_mirror' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '',
|
||||
],
|
||||
'upstream_mirrors.connection' => 'business_mirror',
|
||||
'upstream_mirrors.datasets' => [
|
||||
CourierFreightReconciliationMirrorRepository::DATASET => [
|
||||
'source_key' => 'jiansun',
|
||||
'schema' => 'dbo',
|
||||
'object' => 'fys_express_courier_settlement',
|
||||
'target' => 'js_express_courier_settlement',
|
||||
'keys' => ['Id'],
|
||||
'watermark' => 'modify_time',
|
||||
CourierFreightReconciliationMirrorRepository::DETAILS_DATASET => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_order_express_details',
|
||||
'target' => 'js_jst_order_express_details', 'keys' => ['id'], 'watermark' => 'updatedAt',
|
||||
],
|
||||
CourierFreightReconciliationMirrorRepository::EXTENSION_DATASET => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'Jst_SendData_View_extension',
|
||||
'target' => 'js_jst_send_data_view_extension', 'keys' => ['Id'], 'watermark' => null,
|
||||
],
|
||||
CourierFreightReconciliationMirrorRepository::OUTBOUND_DATASET => [
|
||||
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_salesOutboundInfo',
|
||||
'target' => 'js_jst_sales_outbound_info', 'keys' => ['Id'], 'watermark' => 'updateTime',
|
||||
],
|
||||
],
|
||||
]);
|
||||
@ -46,18 +48,30 @@ class CourierFreightReconciliationMirrorRepositoryTest extends TestCase
|
||||
$table->dateTime('last_succeeded_at')->nullable();
|
||||
$table->string('run_mode')->nullable();
|
||||
});
|
||||
Schema::connection('business_mirror')->create('js_express_courier_settlement', function (Blueprint $table): void {
|
||||
$table->integer('Id');
|
||||
foreach (['month', 'status', 'courier_company_name', 'expresscode', 'expressname', 'replenish_courier', 'reconciliation_person', 'file_status', 'taskid', 'guid', 'creator'] as $column) {
|
||||
Schema::connection('business_mirror')->create('js_jst_order_express_details', function (Blueprint $table): void {
|
||||
$table->integer('id');
|
||||
foreach (['updatedAt', 'so_id', 'logistic_id', 'logistic_company_name', 'platform_name', 'shop_name', 'warehouse_name', 'logistic_status_name'] as $column) {
|
||||
$table->string($column)->nullable();
|
||||
}
|
||||
foreach (['opening_balance', 'courier_provided_amount', 'reconciliation_difference', 'confirmed_amount', 'advance_payment', 'payable_amount', 'actual_payment', 'compensation_amount', 'other_replenishment', 'corporate_payment_amount', 'personal_payment_amount'] as $column) {
|
||||
$table->decimal($column, 20, 6)->nullable();
|
||||
$table->dateTime('send_time')->nullable();
|
||||
$table->decimal('weight', 20, 4)->nullable();
|
||||
$table->decimal('f_weight', 20, 4)->nullable();
|
||||
});
|
||||
Schema::connection('business_mirror')->create('js_jst_send_data_view_extension', function (Blueprint $table): void {
|
||||
$table->integer('Id');
|
||||
$table->integer('io_id')->nullable();
|
||||
$table->integer('qty')->nullable();
|
||||
foreach (['l', 'w', 'h', 'volume'] as $column) {
|
||||
$table->decimal($column, 20, 4)->nullable();
|
||||
}
|
||||
$table->string('waybill')->nullable();
|
||||
$table->dateTime('create_time')->nullable();
|
||||
$table->dateTime('modify_time')->nullable();
|
||||
$table->boolean('is_delete')->nullable();
|
||||
foreach (['so_id', 'sku_ids', 'sku_name', 'shop_name', 'shop_i_id', 'supplier_name', 'receiver_state', 'receiver_city', 'receiver_district', 'expressName', 'wareHoursName'] as $column) {
|
||||
$table->string($column)->nullable();
|
||||
}
|
||||
});
|
||||
Schema::connection('business_mirror')->create('js_jst_sales_outbound_info', function (Blueprint $table): void {
|
||||
$table->integer('Id');
|
||||
$table->integer('io_id')->nullable();
|
||||
$table->decimal('freight', 20, 4)->nullable();
|
||||
});
|
||||
|
||||
$this->repository = new CourierFreightReconciliationMirrorRepository(
|
||||
@ -74,51 +88,78 @@ class CourierFreightReconciliationMirrorRepositoryTest extends TestCase
|
||||
$this->repository->paginate([], 1, 25);
|
||||
}
|
||||
|
||||
public function test_it_filters_sorts_and_summarizes_only_approved_mirror_fields(): void
|
||||
public function test_it_joins_workbook_fields_and_keeps_freight_aggregate_unique(): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
|
||||
'dataset_key' => CourierFreightReconciliationMirrorRepository::DATASET,
|
||||
'initial_completed_at' => '2026-09-01 09:00:00',
|
||||
'status' => 'succeeded',
|
||||
'last_succeeded_at' => now()->toDateTimeString(),
|
||||
$this->markReady();
|
||||
DB::connection('business_mirror')->table('js_jst_order_express_details')->insert([
|
||||
'id' => 1, 'updatedAt' => '2026-09-01 01:00:00', 'so_id' => 'SO-1', 'logistic_id' => 'YT-001',
|
||||
'logistic_company_name' => '圆通速递', 'platform_name' => '淘宝天猫', 'shop_name' => '店铺A',
|
||||
'warehouse_name' => '仓库A', 'send_time' => '2026-09-01 00:02:06', 'weight' => 0.8,
|
||||
'f_weight' => 0.7, 'logistic_status_name' => '已发货',
|
||||
]);
|
||||
DB::connection('business_mirror')->table('js_express_courier_settlement')->insert([
|
||||
$this->row(1, 'YT-001', '圆通速递', 100, 80, 20, '2026-08'),
|
||||
$this->row(2, 'YT-002', '圆通速递', 50, 50, 0, '2026-08'),
|
||||
$this->row(3, 'ZT-003', '中通礼品单', 30, 25, 5, '2026-09'),
|
||||
DB::connection('business_mirror')->table('js_jst_send_data_view_extension')->insert([
|
||||
['Id' => 10, 'io_id' => 100, 'so_id' => 'SO-1', 'qty' => 1, 'l' => 10, 'w' => 20, 'h' => 30,
|
||||
'sku_ids' => 'SKU-OLD', 'sku_name' => '旧商品', 'shop_name' => '旧店铺', 'shop_i_id' => 'OLD-I',
|
||||
'supplier_name' => '供应商A', 'receiver_state' => '江苏省', 'receiver_city' => '常州市',
|
||||
'receiver_district' => '武进区', 'expressName' => '圆通旧', 'wareHoursName' => '旧仓', 'volume' => 6000],
|
||||
['Id' => 11, 'io_id' => 100, 'so_id' => 'SO-1', 'qty' => 2, 'l' => 11, 'w' => 21, 'h' => 31,
|
||||
'sku_ids' => 'SKU-1', 'sku_name' => '演示商品', 'shop_name' => '店铺A', 'shop_i_id' => 'ITEM-1',
|
||||
'supplier_name' => '供应商A', 'receiver_state' => '江苏省', 'receiver_city' => '常州市',
|
||||
'receiver_district' => '武进区', 'expressName' => '圆通速递', 'wareHoursName' => '仓库A', 'volume' => 7161],
|
||||
]);
|
||||
DB::connection('business_mirror')->table('js_jst_sales_outbound_info')->insert([
|
||||
['Id' => 100, 'io_id' => 100, 'freight' => 0.5],
|
||||
['Id' => 101, 'io_id' => 100, 'freight' => 0.8],
|
||||
]);
|
||||
|
||||
$result = $this->repository->paginate([
|
||||
'express_no' => 'YT',
|
||||
'express_name' => '圆通',
|
||||
'month' => '2026-08',
|
||||
], 1, 1);
|
||||
$result = $this->repository->paginate(['express_no' => 'YT', 'month' => '2026-09'], 1, 25);
|
||||
|
||||
$this->assertSame(2, $result['total']);
|
||||
$this->assertSame('YT-002', $result['data'][0]['waybill']);
|
||||
$this->assertSame(150.0, $result['summary']['payable_amount']);
|
||||
$this->assertSame(130.0, $result['summary']['actual_payment']);
|
||||
$this->assertSame(20.0, $result['summary']['reconciliation_difference']);
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame('YT-001', $result['data'][0]['waybill']);
|
||||
$this->assertSame('ITEM-1', $result['data'][0]['product_id']);
|
||||
$this->assertSame('SKU-1', $result['data'][0]['barcode']);
|
||||
$this->assertSame('演示商品', $result['data'][0]['product_name']);
|
||||
$this->assertSame('江苏省', $result['data'][0]['province']);
|
||||
$this->assertSame(2, $result['data'][0]['quantity']);
|
||||
$this->assertSame(0.8, $result['data'][0]['freight']);
|
||||
$this->assertSame(0.8, $result['summary']['freight']);
|
||||
$this->assertSame('fresh', $result['sync']['code']);
|
||||
$this->assertArrayNotHasKey('receiver_address', $result['data'][0]);
|
||||
$this->assertArrayNotHasKey('password', $result['data'][0]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function row(int $id, string $waybill, string $express, float $payable, float $actual, float $difference, string $month): array
|
||||
public function test_it_keeps_a_shipment_visible_when_optional_detail_rows_are_missing(): void
|
||||
{
|
||||
return [
|
||||
'Id' => $id,
|
||||
'waybill' => $waybill,
|
||||
'expressname' => $express,
|
||||
'courier_company_name' => $express,
|
||||
'month' => $month,
|
||||
'status' => '已确认',
|
||||
'payable_amount' => $payable,
|
||||
'actual_payment' => $actual,
|
||||
'confirmed_amount' => $payable,
|
||||
'reconciliation_difference' => $difference,
|
||||
'create_time' => '2026-09-07 09:00:00',
|
||||
'modify_time' => '2026-09-07 10:00:00',
|
||||
];
|
||||
$this->markReady();
|
||||
DB::connection('business_mirror')->table('js_jst_order_express_details')->insert([
|
||||
'id' => 2, 'updatedAt' => '2026-09-02 01:00:00', 'so_id' => 'SO-2',
|
||||
'logistic_id' => 'ZT-002', 'logistic_company_name' => '中通快递',
|
||||
'platform_name' => '京东商城', 'shop_name' => '店铺B', 'warehouse_name' => '仓库B',
|
||||
'send_time' => '2026-09-02 00:00:00', 'logistic_status_name' => '已发货',
|
||||
]);
|
||||
|
||||
$result = $this->repository->paginate(['express_no' => 'ZT-002'], 1, 25);
|
||||
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame('SO-2', $result['data'][0]['transaction_order_no']);
|
||||
$this->assertSame('店铺B', $result['data'][0]['shop_name']);
|
||||
$this->assertSame('仓库B', $result['data'][0]['warehouse']);
|
||||
$this->assertNull($result['data'][0]['product_id']);
|
||||
$this->assertNull($result['data'][0]['freight']);
|
||||
}
|
||||
|
||||
private function markReady(): void
|
||||
{
|
||||
$now = now()->toDateTimeString();
|
||||
foreach ([
|
||||
CourierFreightReconciliationMirrorRepository::DETAILS_DATASET,
|
||||
CourierFreightReconciliationMirrorRepository::EXTENSION_DATASET,
|
||||
CourierFreightReconciliationMirrorRepository::OUTBOUND_DATASET,
|
||||
] as $dataset) {
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
|
||||
'dataset_key' => $dataset, 'initial_completed_at' => '2026-09-01 09:00:00',
|
||||
'status' => 'succeeded', 'last_succeeded_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -174,6 +174,54 @@ class ProductCatalogMirrorRepositoryTest extends TestCase
|
||||
$this->assertSame('pending', $result['purchase_price_sync_status']['code']);
|
||||
}
|
||||
|
||||
public function test_it_attaches_the_latest_effective_work_platform_price_and_marks_the_source(): void
|
||||
{
|
||||
$this->markReady(1);
|
||||
$this->insertProduct(1, ['sku_id' => 'SKU-WORK', 'purchase_price' => 55]);
|
||||
$this->createWorkPlatformPriceTables(true);
|
||||
$this->insertWorkPlatformPrice(10, [
|
||||
'skuCode' => 'SKU-WORK',
|
||||
'purchasePrice' => 60,
|
||||
'effectTime' => '2026-08-18 00:00:00',
|
||||
]);
|
||||
$this->insertWorkPlatformPrice(11, [
|
||||
'skuCode' => 'SKU-WORK',
|
||||
'purchasePrice' => 65,
|
||||
'effectTime' => '2026-08-20 00:00:00',
|
||||
]);
|
||||
$this->insertWorkPlatformPrice(12, [
|
||||
'skuCode' => 'SKU-WORK',
|
||||
'purchasePrice' => 99,
|
||||
'effectTime' => '2099-01-01 00:00:00',
|
||||
]);
|
||||
|
||||
$row = $this->repository->paginate($this->filters(), 1, 25)['data'][0];
|
||||
|
||||
$this->assertSame('55', (string) $row->purchase_price);
|
||||
$this->assertSame('65', (string) $row->work_platform_price);
|
||||
$this->assertStringStartsWith('2026-08-20', (string) $row->work_platform_price_effect_time);
|
||||
$this->assertSame(
|
||||
['商品及库存管理(普通商品资料)', '工作平台价格'],
|
||||
$row->data_sources,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_keeps_products_visible_when_work_platform_price_mirror_is_not_initialized(): void
|
||||
{
|
||||
$this->markReady(1);
|
||||
$this->insertProduct(1, ['sku_id' => 'SKU-WORK-PENDING']);
|
||||
$this->createWorkPlatformPriceTables(false);
|
||||
|
||||
$result = $this->repository->paginate($this->filters(), 1, 25);
|
||||
$row = $result['data'][0];
|
||||
|
||||
$this->assertNull($row->work_platform_price);
|
||||
$this->assertNull($row->work_platform_price_effect_time);
|
||||
$this->assertSame(['商品及库存管理(普通商品资料)'], $row->data_sources);
|
||||
$this->assertFalse($result['work_platform_price_sync_status']['initialized']);
|
||||
$this->assertSame('pending', $result['work_platform_price_sync_status']['code']);
|
||||
}
|
||||
|
||||
public function test_it_exports_all_filtered_rows_in_stable_batches_with_data_sources(): void
|
||||
{
|
||||
$this->markReady(3);
|
||||
@ -370,6 +418,33 @@ class ProductCatalogMirrorRepositoryTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
private function createWorkPlatformPriceTables(bool $initialized): void
|
||||
{
|
||||
Schema::connection('business_mirror')->create('js_pm_price_adjustment', function (Blueprint $table): void {
|
||||
$table->unsignedBigInteger('Id')->primary();
|
||||
$table->string('skuCode')->nullable();
|
||||
$table->decimal('purchasePrice', 18, 2)->nullable();
|
||||
$table->dateTime('effectTime')->nullable();
|
||||
});
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
|
||||
'dataset_key' => ProductCatalogMirrorRepository::WORK_PLATFORM_PRICE_DATASET_KEY,
|
||||
'status' => 'succeeded',
|
||||
'initial_completed_at' => $initialized ? '2026-09-01 03:00:00' : null,
|
||||
'last_succeeded_at' => $initialized ? '2026-09-01 03:01:33' : null,
|
||||
'mirror_row_count' => $initialized ? 2 : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private function insertWorkPlatformPrice(int $id, array $overrides = []): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('js_pm_price_adjustment')->insert(array_merge([
|
||||
'Id' => $id,
|
||||
'skuCode' => 'SKU-' . $id,
|
||||
'purchasePrice' => 50,
|
||||
'effectTime' => '2026-08-01 00:00:00',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
private function createExclusionTable(): void
|
||||
{
|
||||
Schema::connection('business_mirror')->create('product_catalog_exclusions', function (Blueprint $table): void {
|
||||
|
||||
@ -7,6 +7,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
@ -44,6 +45,7 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
|
||||
$table->string('status')->nullable();
|
||||
$table->dateTime('last_succeeded_at')->nullable();
|
||||
$table->unsignedBigInteger('mirror_row_count')->nullable();
|
||||
$table->boolean('mirror_row_count_is_exact')->default(false);
|
||||
});
|
||||
$schema->create('ops_jst_original_bill_info', function (Blueprint $table): void {
|
||||
$table->integer('Id');
|
||||
@ -124,6 +126,68 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
|
||||
$this->assertSame(20.10, $rows[0]['expense']);
|
||||
}
|
||||
|
||||
public function test_export_batches_preserve_sort_ties_nulls_and_channel_filter(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$db = DB::connection('business_mirror');
|
||||
$table = $db->table('ops_jst_original_bill_info');
|
||||
$fixtures = [];
|
||||
for ($id = 1; $id <= 2105; $id++) {
|
||||
$row = $this->row($id, 7, '2026-08-01 09:00:00', '1', 10);
|
||||
$row['fee_time'] = $id <= 1005 ? null : '2026-08-01 09:00:00';
|
||||
$row['amount'] = $id <= 1005 ? null : 10;
|
||||
$fixtures[] = $row;
|
||||
}
|
||||
foreach (array_chunk($fixtures, 100) as $chunk) $table->insert($chunk);
|
||||
$table->insert($this->row(9999, 7, '2026-08-01 09:00:00', '1', 10, 'OtherChannel'));
|
||||
foreach (['occurred_at' => 'fee_time', 'income' => 'amount', 'record_code' => 'Id'] as $sort => $column) {
|
||||
foreach (['asc', 'desc'] as $direction) {
|
||||
$expected = (clone $table)->where('channel', 'CommonBillTGCPay')
|
||||
->orderBy($column, $direction)->orderBy('Id', $direction)->pluck('Id')->map('strval')->all();
|
||||
$db->flushQueryLog();
|
||||
$db->enableQueryLog();
|
||||
$rows = iterator_to_array($this->repository->exportChannelRows([], $sort, $direction, 'CommonBillTGCPay'));
|
||||
$this->assertSame($expected, array_column($rows, 'record_code'));
|
||||
$reads = array_filter($db->getQueryLog(), fn ($query) => str_contains($query['query'], 'select "b".*'));
|
||||
$this->assertCount(3, $reads);
|
||||
foreach ($reads as $read) $this->assertStringContainsString('limit 1000', $read['query']);
|
||||
$db->disableQueryLog();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_export_does_not_chase_new_rows_inserted_after_it_starts(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$table = DB::connection('business_mirror')->table('ops_jst_original_bill_info');
|
||||
for ($id = 1; $id <= 1001; $id++) {
|
||||
$table->insert($this->row($id, 7, '2026-08-01 09:00:00', '1', 10));
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($this->repository->exportRows([], 'record_code', 'asc') as $row) {
|
||||
$ids[] = $row['record_code'];
|
||||
if (count($ids) === 1) $table->insert($this->row(9999, 7, '2026-08-01 09:00:00', '1', 10));
|
||||
}
|
||||
$this->assertCount(1001, $ids);
|
||||
$this->assertNotContains('9999', $ids);
|
||||
}
|
||||
|
||||
public function test_channel_index_migration_and_rollback_preserve_rows(): void
|
||||
{
|
||||
config(['system_database.mode' => 'connected']);
|
||||
$table = DB::connection('business_mirror')->table('ops_jst_original_bill_info');
|
||||
$table->insert($this->row(1, 7, '2026-08-01 09:00:00', '1', 10, str_repeat('A', 101)));
|
||||
$migration = require base_path('database/migrations/2026_09_09_170000_index_original_bill_channel_queries.php');
|
||||
$before = (array) $table->first();
|
||||
$migration->up();
|
||||
$migration->up();
|
||||
$this->assertTrue(Schema::connection('business_mirror')->hasIndex('ops_jst_original_bill_info', 'idx_original_bill_channel_time'));
|
||||
$this->assertEquals($before, (array) $table->first());
|
||||
$migration->down();
|
||||
$this->assertFalse(Schema::connection('business_mirror')->hasIndex('ops_jst_original_bill_info', 'idx_original_bill_channel_time'));
|
||||
$this->assertEquals($before, (array) $table->first());
|
||||
}
|
||||
|
||||
public function test_common_filters_use_the_existing_daily_summary_acceleration(): void
|
||||
{
|
||||
$this->markReady();
|
||||
@ -189,10 +253,44 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
|
||||
$this->assertSame(3, $this->repository->paginate([], 'occurred_at', 'desc', 1, 1)['total']);
|
||||
$state->update(['status' => 'failed']);
|
||||
$this->assertSame(3, $this->repository->paginate([], 'occurred_at', 'desc', 1, 1)['total']);
|
||||
$state->update(['status' => 'running', 'mirror_row_count' => 3, 'mirror_row_count_is_exact' => true]);
|
||||
$db->flushQueryLog();
|
||||
$db->enableQueryLog();
|
||||
$this->assertSame(3, $this->repository->paginate([], 'occurred_at', 'desc', 1, 1)['total']);
|
||||
$this->assertCount(0, array_filter($db->getQueryLog(), fn ($q) => str_contains(strtolower($q['query']), 'count(*)')));
|
||||
$db->disableQueryLog();
|
||||
$state->update(['status' => 'succeeded', 'mirror_row_count' => null]);
|
||||
$this->assertSame(3, $this->repository->paginate([], 'occurred_at', 'desc', 1, 1)['total']);
|
||||
}
|
||||
|
||||
public function test_douyin_reads_only_toutiao_fangxin_gou_rows_and_maps_flow_fields(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$db = DB::connection('business_mirror');
|
||||
$db->table('ops_jst_shop_info')->insert([
|
||||
'Id' => 10, 'shop_id' => 7, 'shop_name' => '放心购旗舰店',
|
||||
]);
|
||||
$db->table('ops_jst_original_bill_info')->insert([
|
||||
$this->row(1, 7, '2026-08-01 09:00:00', '1', 100.25, 'TouTiaoFXGZJ'),
|
||||
$this->row(2, 7, '2026-08-02 09:00:00', '-1', 20.10, 'CommonBillTGCPay'),
|
||||
]);
|
||||
|
||||
$douyin = new StoreReceivingDouyinBillMirrorRepository($this->repository);
|
||||
$result = $douyin->paginate([], 'occurred_at', 'desc', 1, 10);
|
||||
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame('1', $result['data'][0]['record_code']);
|
||||
$this->assertSame('放心购旗舰店', $result['data'][0]['store_name']);
|
||||
$this->assertSame('TouTiaoFXGZJ', $result['data'][0]['payload']['settle_account']);
|
||||
$this->assertSame('头条放心购', $result['data'][0]['payload']['move_scene']);
|
||||
$this->assertSame(100.25, $result['summary']['income']);
|
||||
$this->assertSame(0.0, $result['summary']['expense']);
|
||||
|
||||
$rows = iterator_to_array($douyin->exportRows([], 'occurred_at', 'asc'));
|
||||
$this->assertCount(1, $rows);
|
||||
$this->assertSame('1', $rows[0]['record_code']);
|
||||
}
|
||||
|
||||
private function markReady(): void
|
||||
{
|
||||
foreach ([StoreReceivingAlipayBillMirrorRepository::DATASET, StoreReceivingAlipayBillMirrorRepository::SHOP_DATASET] as $dataset) {
|
||||
@ -205,10 +303,17 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
private function row(int $id, int $shopId, string $time, string $direction, float $amount): array
|
||||
private function row(
|
||||
int $id,
|
||||
int $shopId,
|
||||
string $time,
|
||||
string $direction,
|
||||
float $amount,
|
||||
string $channel = 'CommonBillTGCPay',
|
||||
): array
|
||||
{
|
||||
return [
|
||||
'Id' => $id, 'shop_id' => $shopId, 'channel' => 'CommonBillTGCPay',
|
||||
'Id' => $id, 'shop_id' => $shopId, 'channel' => $channel,
|
||||
'primary_key' => 'KEY-' . $id, 'fee_name' => '交易佣金', 'inout_type' => $direction,
|
||||
'p_auto_id' => 'P-' . $id, 'shop_i_id' => 'STYLE-' . $id,
|
||||
'shop_sku_id' => 'SHOP-SKU-' . $id, 'sku_id' => 'SKU-' . $id,
|
||||
|
||||
35
tests/Unit/EcommerceFinance/WorkOrderLookupMigrationTest.php
Normal file
35
tests/Unit/EcommerceFinance/WorkOrderLookupMigrationTest.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WorkOrderLookupMigrationTest extends TestCase
|
||||
{
|
||||
public function test_lookup_indexes_preserve_values_and_are_reversible(): void
|
||||
{
|
||||
config(['database.connections.work_order_index_fixture' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''],
|
||||
'upstream_mirrors.connection' => 'work_order_index_fixture', 'system_database.mode' => 'shared']);
|
||||
$schema = Schema::connection('work_order_index_fixture');
|
||||
$db = DB::connection('work_order_index_fixture');
|
||||
foreach (['ops_work_order_sub', 'ops_work_order_form_value'] as $table) {
|
||||
$schema->create($table, static function (Blueprint $t): void {
|
||||
$t->integer('id'); $t->longText('main_order_no')->nullable(); $t->integer('sort')->nullable();
|
||||
});
|
||||
$db->table($table)->insert(['id' => 1, 'main_order_no' => str_repeat('1', 100), 'sort' => 0]);
|
||||
}
|
||||
$migration = require base_path('database/migrations/2026_09_09_140000_index_after_sales_work_order_lookups.php');
|
||||
$migration->up();
|
||||
$migration->up();
|
||||
$this->assertTrue($schema->hasIndex('ops_work_order_sub', 'idx_work_order_sub_parent'));
|
||||
$this->assertTrue($schema->hasIndex('ops_work_order_form_value', 'idx_work_order_value_parent'));
|
||||
$this->assertSame(str_repeat('1', 100), $db->table('ops_work_order_form_value')->value('main_order_no'));
|
||||
$migration->down();
|
||||
$this->assertFalse($schema->hasIndex('ops_work_order_sub', 'idx_work_order_sub_parent'));
|
||||
$this->assertSame(1, $db->table('ops_work_order_sub')->count());
|
||||
DB::purge('work_order_index_fixture');
|
||||
}
|
||||
}
|
||||
31
tests/Unit/EcommerceFinance/WorkPlatformPriceConfigTest.php
Normal file
31
tests/Unit/EcommerceFinance/WorkPlatformPriceConfigTest.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WorkPlatformPriceConfigTest extends TestCase
|
||||
{
|
||||
public function test_work_platform_price_dataset_is_wired_to_the_read_only_source(): void
|
||||
{
|
||||
$dataset = (new BusinessMirrorDatasetRegistry())->get(
|
||||
ProductCatalogMirrorRepository::WORK_PLATFORM_PRICE_DATASET_KEY,
|
||||
);
|
||||
|
||||
$this->assertSame('jiansun.dbo.pm_priceAdjustment', $dataset->key());
|
||||
$this->assertSame('jiansun', $dataset->sourceKey());
|
||||
$this->assertSame('dbo', $dataset->sourceSchema());
|
||||
$this->assertSame('pm_priceAdjustment', $dataset->sourceObject());
|
||||
$this->assertSame('js_pm_price_adjustment', $dataset->targetTable());
|
||||
$this->assertSame(['Id'], $dataset->keyColumns());
|
||||
$this->assertNull($dataset->watermarkColumn());
|
||||
$this->assertContains(['skuCode(64)', 'effectTime', 'Id'], $dataset->indexDefinitions());
|
||||
|
||||
$selection = array_map('strtolower', (new BusinessMirrorDatasetRegistry())->columnSelection($dataset->key()) ?? []);
|
||||
foreach (['id', 'skucode', 'purchaseprice', 'effecttime'] as $column) {
|
||||
$this->assertContains($column, $selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user