fix: align bill offset source and close release verification gaps
This commit is contained in:
parent
7cba2d4770
commit
367cf10728
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -25,7 +25,7 @@ use Illuminate\Support\Collection;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
|
||||
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
|
||||
use Modules\EcommerceFinance\Repositories\BillOffsetMirrorRepository;
|
||||
use Modules\EcommerceFinance\View\BillOffsetPageData;
|
||||
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
|
||||
@ -269,18 +269,9 @@ class Pages extends Controller
|
||||
'shop_name' => mb_substr(trim((string) $request->query('shop_name', '')), 0, 100),
|
||||
'sku_id' => mb_substr(trim((string) $request->query('sku_id', '')), 0, 100),
|
||||
];
|
||||
$repository = app(BillOffsetMirrorRepository::class);
|
||||
$page = 1;
|
||||
try {
|
||||
$result = $repository->paginate($filters, $page, 100);
|
||||
} catch (\RuntimeException $exception) {
|
||||
if ($exception->getMessage() === 'bill_offset_mirror_not_ready') {
|
||||
abort(503, '账单冲减镜像尚未完成首次同步');
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
abort_if(($result['sync']['status'] ?? 'unknown') === 'unknown', 503, '账单冲减镜像尚未完成首次同步');
|
||||
$repository = app(BillOffsetPageData::class);
|
||||
$result = $repository->data($filters, 1, 100);
|
||||
abort_if($result['billOffsetError'] !== null, 503, $result['billOffsetError']);
|
||||
|
||||
return response()->streamDownload(function () use ($repository, $filters, $result): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
@ -292,23 +283,28 @@ class Pages extends Controller
|
||||
'internal_order_no' => '内部订单号', 'online_order_no' => '线上订单号',
|
||||
'shop_name' => '店铺名称', 'shop_code' => '店铺编码', 'product_code' => '商品编码',
|
||||
'style_code' => '款式编码(参考)', 'shop_product_code' => '店铺商品编码',
|
||||
'shop_style_code' => '店铺款式编码', 'amount' => '500112冲减收入',
|
||||
'shop_style_code' => '店铺款式编码', 'account_500112' => '500112冲减收入',
|
||||
];
|
||||
fputcsv($csv, array_values($labels));
|
||||
$page = 1;
|
||||
$current = $result;
|
||||
do {
|
||||
foreach ($current['data'] as $row) {
|
||||
foreach ($current['billOffsetRows'] as $row) {
|
||||
fputcsv($csv, array_map(
|
||||
fn (string $field): string => $this->csvText($row[$field] ?? ''),
|
||||
fn (string $field): string => $field === 'account_500112'
|
||||
? (string) (data_get($row, $field) ?? '')
|
||||
: ($field === 'shop_name'
|
||||
? $this->csvText(data_get($row, $field) ?? '')
|
||||
: $this->csvIdentifier(data_get($row, $field) ?? '')),
|
||||
array_keys($labels),
|
||||
));
|
||||
}
|
||||
$page++;
|
||||
if (($page - 1) * 100 < ($current['total'] ?? 0)) {
|
||||
$current = $repository->paginate($filters, $page, 100);
|
||||
if (! $current['billOffsetRows']->hasMorePages()) break;
|
||||
$current = $repository->data($filters, ++$page, 100);
|
||||
if ($current['billOffsetError'] !== null) {
|
||||
throw new \RuntimeException('账单冲减导出未完成,请重试。');
|
||||
}
|
||||
} while (($page - 1) * 100 < ($current['total'] ?? 0));
|
||||
} while (true);
|
||||
fclose($csv);
|
||||
}, '账单冲减_' . now()->format('Ymd_His') . '.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
|
||||
@ -73,8 +73,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.
|
||||
|
||||
@ -260,6 +260,7 @@ return [
|
||||
],
|
||||
'indexes' => [
|
||||
['work_type', 'id'],
|
||||
['work_type', 'create_time', 'id'],
|
||||
['create_time', 'id'],
|
||||
],
|
||||
],
|
||||
@ -279,6 +280,7 @@ return [
|
||||
],
|
||||
'indexes' => [
|
||||
['id'],
|
||||
['main_order_no', 'id'],
|
||||
],
|
||||
],
|
||||
'operations_replica.rpt.work_order_form_config' => [
|
||||
@ -312,6 +314,7 @@ return [
|
||||
],
|
||||
'indexes' => [
|
||||
['sort', 'id'],
|
||||
['main_order_no', 'sort', 'id'],
|
||||
],
|
||||
],
|
||||
|
||||
@ -417,7 +420,7 @@ return [
|
||||
'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' => [['io_id', 'Id']],
|
||||
'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',
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -77,7 +77,7 @@
|
||||
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。
|
||||
|
||||
## 后续切换顺序
|
||||
|
||||
|
||||
@ -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) . '%';
|
||||
}
|
||||
}
|
||||
@ -148,7 +148,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 +169,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;
|
||||
|
||||
@ -2,79 +2,34 @@
|
||||
|
||||
namespace Modules\EcommerceFinance\View;
|
||||
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\EcommerceFinance\Repositories\BillOffsetMirrorRepository;
|
||||
use Throwable;
|
||||
|
||||
/** 账单冲减复用原始数据的订单售后费用明细,不重新推断科目。 */
|
||||
class BillOffsetPageData
|
||||
{
|
||||
public function __construct(private readonly BillOffsetMirrorRepository $bills)
|
||||
public function __construct(private readonly OrderDataPageData $orders)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function page(): array
|
||||
{
|
||||
$filters = [
|
||||
'from' => $this->date(request('from')) ?: now()->subDays(6)->toDateString(),
|
||||
'to' => $this->date(request('to')) ?: now()->toDateString(),
|
||||
'order_no' => $this->text(request('order_no')),
|
||||
'shop_name' => $this->text(request('shop_name')),
|
||||
'sku_id' => $this->text(request('sku_id')),
|
||||
];
|
||||
$page = max((int) request('page', 1), 1);
|
||||
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
|
||||
$rows = new LengthAwarePaginator([], 0, $limit, $page, [
|
||||
'path' => request()->url(),
|
||||
'query' => request()->except('page'),
|
||||
]);
|
||||
$summary = ['amount' => 0.0, 'count' => 0];
|
||||
$sync = ['status' => 'unavailable', 'last_succeeded_at' => null];
|
||||
$error = null;
|
||||
return ['view' => 'business.pages.order-data.bill-offset'] + $this->data();
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->bills->paginate($filters, $page, $limit);
|
||||
$rows = new LengthAwarePaginator(
|
||||
Collection::make($result['data'] ?? []),
|
||||
(int) ($result['total'] ?? 0),
|
||||
$limit,
|
||||
$page,
|
||||
['path' => request()->url(), 'query' => request()->except('page')],
|
||||
);
|
||||
$summary = $result['summary'] ?? $summary;
|
||||
$sync = $result['sync'] ?? $sync;
|
||||
} catch (Throwable $exception) {
|
||||
$mirrorNotReady = str_contains($exception->getMessage(), 'bill_offset_mirror_not_ready');
|
||||
if (! $mirrorNotReady) {
|
||||
report($exception);
|
||||
}
|
||||
$error = $mirrorNotReady
|
||||
? '账单冲减镜像尚未完成首次同步,当前不把空镜像解释为没有数据。'
|
||||
: '账单冲减数据暂时无法读取,请稍后重试。';
|
||||
}
|
||||
public function data(?array $filters = null, ?int $page = null, ?int $limit = null): array
|
||||
{
|
||||
$source = $this->orders->afterSales($filters, $page, $limit, false);
|
||||
$rows = $source['orderDataRows'];
|
||||
$amounts = collect($rows->items())->pluck('account_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,
|
||||
'billOffsetSummary' => $summary,
|
||||
'billOffsetSync' => $sync,
|
||||
'billOffsetError' => $error,
|
||||
'billOffsetFilters' => $source['orderDataFilters'],
|
||||
'billOffsetSummary' => ['count' => $rows->count(), 'amount' => $amount],
|
||||
'billOffsetStatus' => $source['orderDataStatus']['X-Caiwu-Data-Status'] ?? null,
|
||||
'billOffsetError' => $source['orderDataError'],
|
||||
];
|
||||
}
|
||||
|
||||
private function text(mixed $value): string
|
||||
{
|
||||
return mb_substr(trim((string) $value), 0, 100);
|
||||
}
|
||||
|
||||
private function date(mixed $value): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches) !== 1) {
|
||||
return '';
|
||||
}
|
||||
return checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1]) ? $value : '';
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,16 +23,18 @@ class OrderDataPageData
|
||||
) {
|
||||
}
|
||||
|
||||
public function afterSales(): array
|
||||
public function afterSales(?array $filters = null, ?int $page = null, ?int $limit = null, bool $includeWorkOrders = true): array
|
||||
{
|
||||
$filters = [
|
||||
$filters ??= [
|
||||
'from' => $this->date(request('from')) ?: now()->subDays(6)->toDateString(),
|
||||
'to' => $this->date(request('to')) ?: now()->toDateString(),
|
||||
'order_no' => $this->text(request('order_no')),
|
||||
'shop_name' => $this->text(request('shop_name')),
|
||||
'sku_id' => $this->text(request('sku_id')),
|
||||
];
|
||||
[$page, $limit] = $this->pageSettings();
|
||||
[$requestPage, $requestLimit] = $this->pageSettings();
|
||||
$page = max($page ?? $requestPage, 1);
|
||||
$limit = min(max($limit ?? $requestLimit, 1), 100);
|
||||
|
||||
try {
|
||||
$mirror = $this->mirrorOrders ?? app(OrderDataMirrorRepository::class);
|
||||
@ -44,7 +46,7 @@ class OrderDataPageData
|
||||
'orderDataFilters' => $filters,
|
||||
'orderDataStatus' => ['X-Caiwu-Data-Status' => 'mirror'],
|
||||
'orderDataError' => null,
|
||||
'afterSalesWorkOrders' => $this->afterSalesWorkOrders($filters),
|
||||
'afterSalesWorkOrders' => $includeWorkOrders ? $this->afterSalesWorkOrders($filters) : $this->emptyAfterSalesWorkOrders(),
|
||||
];
|
||||
}
|
||||
|
||||
@ -60,7 +62,7 @@ class OrderDataPageData
|
||||
'orderDataFilters' => $filters,
|
||||
'orderDataStatus' => $cache->responseHeaders(),
|
||||
'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>
|
||||
|
||||
11
resources/assets/js/views/business/pages.js
vendored
11
resources/assets/js/views/business/pages.js
vendored
@ -61,6 +61,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 +83,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 +104,7 @@ if (mount) {
|
||||
if (resolved) {
|
||||
return h(resolved.Component, {
|
||||
key: this.context.tab,
|
||||
props: resolved.props,
|
||||
props: { ...resolved.props, userKey: preferencesUser },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
.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 { 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:repeat(4,minmax(10rem,1fr)); gap:.7rem; align-items:end; padding:1rem 1.15rem; background:#fffdf8; }
|
||||
@ -236,7 +236,11 @@
|
||||
applyColumns();
|
||||
};
|
||||
|
||||
const csvEscape = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
|
||||
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(',');
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
$rows = $billOffsetRows ?? null;
|
||||
$filters = $billOffsetFilters ?? [];
|
||||
$summary = $billOffsetSummary ?? ['amount' => 0, 'count' => 0];
|
||||
$sync = $billOffsetSync ?? ['status' => 'unavailable', 'last_succeeded_at' => null];
|
||||
$status = $billOffsetStatus ?? null;
|
||||
$columns = [
|
||||
'internal_order_no' => '内部订单号',
|
||||
'online_order_no' => '线上订单号',
|
||||
@ -12,7 +12,7 @@
|
||||
'style_code' => '款式编码(参考)',
|
||||
'shop_product_code' => '店铺商品编码',
|
||||
'shop_style_code' => '店铺款式编码',
|
||||
'amount' => '500112冲减收入',
|
||||
'account_500112' => '500112冲减收入',
|
||||
];
|
||||
@endphp
|
||||
|
||||
@ -59,7 +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>
|
||||
<a id="bill-offset-export-button" class="upstream-data-button{{ (!$rows || $rows->total() === 0) ? ' is-disabled' : '' }}" href="{{ route('business.bill-offset.export', $filters) }}" @if (!$rows || $rows->total() === 0) 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>
|
||||
@ -71,21 +71,21 @@
|
||||
|
||||
<div class="upstream-data-table-panel">
|
||||
<div class="bill-offset-summary" aria-label="账单冲减汇总">
|
||||
<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>冲减收入合计</span><strong class="bill-offset-summary__value">{{ number_format((float) ($summary['amount'] ?? 0), 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>本页冲减收入合计</span><strong class="bill-offset-summary__value">{{ $summary['amount'] === null ? '-' : number_format((float) $summary['amount'], 2) }}</strong></div>
|
||||
</div>
|
||||
@if (!empty($sync['last_succeeded_at']))
|
||||
<div class="bill-offset-sync">数据状态:{{ strtoupper((string) ($sync['status'] ?? 'mirror')) }} · 最近同步 {{ $sync['last_succeeded_at'] }}</div>
|
||||
@if ($status)
|
||||
<div class="bill-offset-sync">数据状态:{{ strtoupper((string) $status) }}</div>
|
||||
@endif
|
||||
<div class="upstream-data-table-scroll">
|
||||
<table id="bill-offset-table" class="upstream-data-table bill-offset-table--wide" aria-label="账单冲减">
|
||||
<thead><tr>@foreach ($columns as $field => $header)<th @class(['is-number' => $field === 'amount'])>{{ $header }}</th>@endforeach</tr></thead>
|
||||
<thead><tr>@foreach ($columns as $field => $header)<th @class(['is-number' => $field === 'account_500112'])>{{ $header }}</th>@endforeach</tr></thead>
|
||||
<tbody>
|
||||
@forelse ($rows ?? [] as $row)
|
||||
<tr>
|
||||
@foreach ($columns as $field => $header)
|
||||
@php $value = data_get($row, $field); @endphp
|
||||
<td data-export-value="{{ $value ?? '' }}" @class(['is-number' => $field === 'amount'])>{{ $field === 'amount' ? number_format((float) ($value ?? 0), 2) : (($value === null || $value === '') ? '-' : $value) }}</td>
|
||||
<td data-export-value="{{ $value ?? '' }}" @class(['is-number' => $field === 'account_500112'])>{{ ($value === null || $value === '') ? '-' : ($field === 'account_500112' ? number_format((float) $value, 2) : $value) }}</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
@empty
|
||||
@ -95,10 +95,10 @@
|
||||
</table>
|
||||
</div>
|
||||
<div class="upstream-data-pagination">
|
||||
<span>当前页 {{ $rows?->count() ?? 0 }} 条 · 共 {{ $rows?->total() ?? 0 }} 条</span>
|
||||
<span>当前页 {{ $rows?->count() ?? 0 }} 条</span>
|
||||
<div class="upstream-data-pagination__controls">
|
||||
<a href="{{ $rows?->previousPageUrl() ?? '#' }}" @class(['upstream-data-button', 'is-disabled' => !$rows || $rows->onFirstPage()]) aria-label="上一页"> <span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
|
||||
<span>第 {{ $rows?->currentPage() ?? 1 }} / {{ $rows?->lastPage() ?? 1 }} 页</span>
|
||||
<span>第 {{ $rows?->currentPage() ?? 1 }} 页</span>
|
||||
<a href="{{ $rows?->nextPageUrl() ?? '#' }}" @class(['upstream-data-button', 'is-disabled' => !$rows?->hasMorePages()]) aria-label="下一页"> <span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -4,7 +4,7 @@ namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Repositories\BillOffsetMirrorRepository;
|
||||
use Modules\EcommerceFinance\View\OrderDataPageData;
|
||||
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
|
||||
@ -273,120 +273,83 @@ class PagesTest extends FeatureTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testBillOffsetPageRendersRowsSummaryAndExportLink(): void
|
||||
private function billOffsetSourceRows(array $rows, array $filters = [], bool $hasMore = false, ?string $error = null): array
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
$repository = Mockery::mock(BillOffsetMirrorRepository::class);
|
||||
$repository->shouldReceive('paginate')->once()->andReturn([
|
||||
'data' => [[
|
||||
'internal_order_no' => '9228703', 'online_order_no' => '5127724585360006527',
|
||||
'shop_name' => '天猫-亿家达旗舰店', 'shop_code' => 'SHOP-1', 'product_code' => 'DNZ005374N79V01',
|
||||
'style_code' => 'STYLE-1', 'shop_product_code' => 'SP-1', 'shop_style_code' => 'SI-1', 'amount' => -20,
|
||||
]],
|
||||
'total' => 1, 'current_page' => 1, 'last_page' => 1, 'per_page' => 25,
|
||||
'summary' => ['amount' => -20, 'count' => 1],
|
||||
'sync' => ['status' => 'succeeded', 'last_succeeded_at' => '2026-09-08 09:00:00'],
|
||||
]);
|
||||
$this->app->instance(BillOffsetMirrorRepository::class, $repository);
|
||||
$paginator = new \Illuminate\Pagination\Paginator($rows, 100, 1);
|
||||
$paginator->hasMorePagesWhen($hasMore);
|
||||
|
||||
return [
|
||||
'orderDataRows' => $paginator,
|
||||
'orderDataFilters' => $filters,
|
||||
'orderDataStatus' => ['X-Caiwu-Data-Status' => 'mirror'],
|
||||
'orderDataError' => $error,
|
||||
];
|
||||
}
|
||||
|
||||
public function testBillOffsetPageUsesTheSameOriginalOrderRowsAndAccountField(): void
|
||||
{
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$source = Mockery::mock(OrderDataPageData::class);
|
||||
$source->shouldReceive('afterSales')->once()->with(null, null, null, false)->andReturn($this->billOffsetSourceRows([
|
||||
['internal_order_no' => '9228703', 'online_order_no' => '5127724585360006527',
|
||||
'shop_name' => '测试店铺', 'product_code' => 'SKU-1', 'account_500112' => -20,
|
||||
'reversal_order_amount' => 987.65],
|
||||
], ['from' => '2026-08-01', 'to' => '2026-08-31']));
|
||||
$this->app->instance(OrderDataPageData::class, $source);
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'order-data', 'page' => 'bill-offset', 'from' => '2026-08-01', 'to' => '2026-08-31',
|
||||
]))->assertOk()
|
||||
->assertSeeText('9228703')
|
||||
->assertSeeText('天猫-亿家达旗舰店')
|
||||
->assertSeeText('-20.00')
|
||||
->assertSee(e(route('business.bill-offset.export', [
|
||||
'from' => '2026-08-01',
|
||||
'to' => '2026-08-31',
|
||||
])), false)
|
||||
->assertSee('id="bill-offset-export-button"', false)
|
||||
->assertSeeText('数据状态:SUCCEEDED');
|
||||
]))->assertOk()->assertSeeText('9228703')->assertSeeText('测试店铺')->assertSeeText('-20.00')
|
||||
->assertDontSeeText('987.65')->assertSeeText('本页冲减收入合计')
|
||||
->assertSee('id="bill-offset-export-button"', false)->assertSeeText('数据状态:MIRROR');
|
||||
}
|
||||
|
||||
public function testBillOffsetPageShowsReadFailureSeparatelyFromAnUnreadyMirror(): void
|
||||
public function testBillOffsetKeepsUnknownAccountAmountsEmpty(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
$repository = Mockery::mock(BillOffsetMirrorRepository::class);
|
||||
$repository->shouldReceive('paginate')->once()->andThrow(new \RuntimeException('database unavailable'));
|
||||
$this->app->instance(BillOffsetMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'order-data', 'page' => 'bill-offset',
|
||||
]))->assertOk()
|
||||
->assertSeeText('账单冲减数据暂时无法读取,请稍后重试。')
|
||||
->assertDontSeeText('账单冲减镜像尚未完成首次同步,当前不把空镜像解释为没有数据。');
|
||||
}
|
||||
|
||||
public function testBillOffsetExportReturnsCsvForTheFilteredRows(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
$repository = Mockery::mock(BillOffsetMirrorRepository::class);
|
||||
$repository->shouldReceive('paginate')->once()->with([
|
||||
'from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => '9228703', 'shop_name' => '', 'sku_id' => '',
|
||||
], 1, 100)->andReturn([
|
||||
'data' => [[
|
||||
'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', 'amount' => -20,
|
||||
]],
|
||||
'total' => 1, 'sync' => ['status' => 'succeeded', 'last_succeeded_at' => '2026-09-08 09:00:00'],
|
||||
]);
|
||||
$this->app->instance(BillOffsetMirrorRepository::class, $repository);
|
||||
|
||||
$response = $this->loginAs()->get(route('business.bill-offset.export', [
|
||||
'from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => '9228703',
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$source = Mockery::mock(OrderDataPageData::class);
|
||||
$source->shouldReceive('afterSales')->once()->andReturn($this->billOffsetSourceRows([
|
||||
['internal_order_no' => 'ORDER-EMPTY', 'account_500112' => null, 'reversal_order_amount' => 987.65],
|
||||
]));
|
||||
$this->app->instance(OrderDataPageData::class, $source);
|
||||
$this->loginAs()->get(route('business.pages.show', ['section' => 'order-data', 'page' => 'bill-offset']))
|
||||
->assertOk()->assertSeeText('ORDER-EMPTY')->assertDontSeeText('987.65')->assertDontSeeText('0.00');
|
||||
}
|
||||
|
||||
$response->assertOk()
|
||||
->assertHeader('Content-Type', 'text/csv; charset=UTF-8');
|
||||
$this->assertStringContainsString('.csv', (string) $response->headers->get('Content-Disposition'));
|
||||
public function testBillOffsetShowsTheOriginalDataErrorAndBlocksAnEmptyExport(): void
|
||||
{
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$source = Mockery::mock(OrderDataPageData::class);
|
||||
$source->shouldReceive('afterSales')->twice()->andReturn($this->billOffsetSourceRows([], [], false, '订单售后费用数据暂时无法读取,请稍后重试。'));
|
||||
$this->app->instance(OrderDataPageData::class, $source);
|
||||
$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());
|
||||
$source = Mockery::mock(OrderDataPageData::class);
|
||||
$filters = ['from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => 'ORDER', 'shop_name' => '', 'sku_id' => ''];
|
||||
$source->shouldReceive('afterSales')->once()->with($filters, 1, 100, false)->andReturn($this->billOffsetSourceRows([
|
||||
['internal_order_no' => 'ORDER-1', 'online_order_no' => '001234567890123456789', 'shop_name' => '测试店铺', 'account_500112' => -20],
|
||||
], $filters, true));
|
||||
$source->shouldReceive('afterSales')->once()->with($filters, 2, 100, false)->andReturn($this->billOffsetSourceRows([
|
||||
['internal_order_no' => 'ORDER-101', 'account_500112' => null, 'reversal_order_amount' => 987.65],
|
||||
], $filters));
|
||||
$this->app->instance(OrderDataPageData::class, $source);
|
||||
$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', $csv);
|
||||
}
|
||||
|
||||
public function testBillOffsetExportIncludesAllPagesForTheSameFilters(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
$repository = Mockery::mock(BillOffsetMirrorRepository::class);
|
||||
$filters = [
|
||||
'from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => '', 'shop_name' => '', 'sku_id' => '',
|
||||
];
|
||||
$repository->shouldReceive('paginate')->once()->with($filters, 1, 100)->andReturn([
|
||||
'data' => [[
|
||||
'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', 'amount' => -20,
|
||||
]],
|
||||
'total' => 101, 'sync' => ['status' => 'succeeded'],
|
||||
]);
|
||||
$repository->shouldReceive('paginate')->once()->with($filters, 2, 100)->andReturn([
|
||||
'data' => [[
|
||||
'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', 'amount' => 18.25,
|
||||
]],
|
||||
'total' => 101, 'sync' => ['status' => 'succeeded'],
|
||||
]);
|
||||
$this->app->instance(BillOffsetMirrorRepository::class, $repository);
|
||||
|
||||
$csv = $this->loginAs()->get(route('business.bill-offset.export', [
|
||||
'from' => '2026-08-01', 'to' => '2026-08-31',
|
||||
]))->streamedContent();
|
||||
|
||||
$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
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
@ -82,4 +82,13 @@ class BusinessDataPlacementTest extends TestCase
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -317,6 +317,24 @@ 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_empty_full_read_does_not_delete_previous_mirror_rows(): void
|
||||
{
|
||||
$this->query->batches = [[
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user