修复非京喜退款数据页面固定空状态 #75
@ -18,6 +18,7 @@ use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
|
||||
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
|
||||
use Modules\EcommerceFinance\Repositories\InventoryRepository;
|
||||
use Modules\EcommerceFinance\Repositories\JdWalletBillRepository;
|
||||
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
|
||||
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
|
||||
@ -39,6 +40,7 @@ class Pages extends Controller
|
||||
public function __construct(
|
||||
private readonly EdgeBandingRepository $edgeBanding,
|
||||
private readonly InventoryRepository $inventory,
|
||||
private readonly NonJingxiRefundMirrorRepository $nonJingxiRefunds,
|
||||
private readonly ProductCatalogMirrorRepository $productCatalog,
|
||||
private readonly ProductPriceMirrorRepository $productPrices,
|
||||
private readonly RawBoardPayablesRepository $rawBoardPayables,
|
||||
@ -236,6 +238,7 @@ class Pages extends Controller
|
||||
return match ($page) {
|
||||
'marketing-account-bills' => $this->contentForMarketingAccountBills($tab),
|
||||
'marketing-account-balance' => $this->contentForMarketingAccountBalance($tab),
|
||||
'non-jingxi-refund-data' => $this->contentForNonJingxiRefundData(),
|
||||
'zhongcai-reconciliation' => $this->contentForZhongcaiReconciliation(),
|
||||
'normal-products' => $this->contentForNormalProducts(),
|
||||
'product-prices' => $this->contentForProductPrices(),
|
||||
@ -245,6 +248,72 @@ class Pages extends Controller
|
||||
};
|
||||
}
|
||||
|
||||
protected function contentForNonJingxiRefundData(): array
|
||||
{
|
||||
$request = request();
|
||||
$defaultTo = now()->toDateString();
|
||||
$defaultFrom = now()->subDays(6)->toDateString();
|
||||
$from = $this->dateFilter($request->query('from', $defaultFrom)) ?: $defaultFrom;
|
||||
$to = $this->dateFilter($request->query('to', $defaultTo)) ?: $defaultTo;
|
||||
if ($from > $to) {
|
||||
[$from, $to] = [$to, $from];
|
||||
}
|
||||
|
||||
$filters = [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'order_no' => mb_substr(trim((string) $request->query('order_no', '')), 0, 100),
|
||||
'shop_name' => mb_substr(trim((string) $request->query('shop_name', '')), 0, 191),
|
||||
'sku_id' => mb_substr(trim((string) $request->query('sku_id', '')), 0, 100),
|
||||
];
|
||||
$page = max((int) $request->query('page', 1), 1);
|
||||
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
|
||||
$rows = new LengthAwarePaginator([], 0, $limit, 1, [
|
||||
'path' => $request->url(),
|
||||
'query' => $request->query(),
|
||||
]);
|
||||
$syncStatus = ['code' => 'unavailable', 'last_succeeded_at' => null];
|
||||
$error = null;
|
||||
$pending = false;
|
||||
$indexPending = false;
|
||||
|
||||
try {
|
||||
if (! $this->nonJingxiRefunds->available()) {
|
||||
$error = '共享业务镜像尚未建立,非京喜退款数据暂时无法读取。';
|
||||
} else {
|
||||
$states = $this->nonJingxiRefunds->states();
|
||||
$syncStatus = $this->nonJingxiRefunds->syncStatus($states);
|
||||
if (! $this->nonJingxiRefunds->ready()) {
|
||||
$pending = true;
|
||||
} elseif (! $this->nonJingxiRefunds->queryReady()) {
|
||||
$indexPending = true;
|
||||
} else {
|
||||
$result = $this->nonJingxiRefunds->paginate($filters, $page, $limit);
|
||||
$rows = new LengthAwarePaginator(
|
||||
Collection::make($result['data'] ?? []),
|
||||
(int) ($result['total'] ?? 0),
|
||||
$limit,
|
||||
$page,
|
||||
['path' => $request->url(), 'query' => $request->query()],
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$error = '非京喜退款数据暂时无法读取,请稍后重试。';
|
||||
}
|
||||
|
||||
return [
|
||||
'view' => 'business.pages.refunds.non-jingxi-refund-data',
|
||||
'filters' => $filters,
|
||||
'rows' => $rows,
|
||||
'refundSyncStatus' => $syncStatus,
|
||||
'refundError' => $error,
|
||||
'refundPending' => $pending,
|
||||
'refundIndexPending' => $indexPending,
|
||||
];
|
||||
}
|
||||
|
||||
protected function contentForMarketingAccountBalance(?string $tab): array
|
||||
{
|
||||
$content = [
|
||||
|
||||
@ -72,8 +72,27 @@ return [
|
||||
'operations_replica.rpt.fys_companyInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_companyInfo', 'target' => 'ops_fys_company_info', 'keys' => ['Id'], 'watermark' => 'updateTIme'],
|
||||
'operations_replica.rpt.fys_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_shopInfo', 'target' => 'ops_fys_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
|
||||
'operations_replica.rpt.fys_TrademarkInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_TrademarkInfo', 'target' => 'ops_fys_trademark_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
|
||||
'operations_replica.rpt.jst_Orders' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_Orders', 'target' => 'ops_jst_orders', 'keys' => ['Id'], 'watermark' => 'updateTime'],
|
||||
'operations_replica.rpt.jst_OrderItem' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_OrderItem', 'target' => 'ops_jst_order_item', 'keys' => ['Id']],
|
||||
'operations_replica.rpt.jst_Orders' => [
|
||||
'source_key' => 'operations_replica',
|
||||
'schema' => 'rpt',
|
||||
'object' => 'jst_Orders',
|
||||
'target' => 'ops_jst_orders',
|
||||
'keys' => ['Id'],
|
||||
'watermark' => 'updateTime',
|
||||
'indexes' => [
|
||||
['o_id', 'Id'],
|
||||
],
|
||||
],
|
||||
'operations_replica.rpt.jst_OrderItem' => [
|
||||
'source_key' => 'operations_replica',
|
||||
'schema' => 'rpt',
|
||||
'object' => 'jst_OrderItem',
|
||||
'target' => 'ops_jst_order_item',
|
||||
'keys' => ['Id'],
|
||||
'indexes' => [
|
||||
['o_id', 'Id'],
|
||||
],
|
||||
],
|
||||
'operations_replica.rpt.jst_sku' => [
|
||||
'source_key' => 'operations_replica',
|
||||
'schema' => 'rpt',
|
||||
@ -92,8 +111,29 @@ return [
|
||||
'operations_replica.rpt.jst_warehouseInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_warehouseInfo', 'target' => 'ops_jst_warehouse_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
|
||||
'operations_replica.rpt.jst_skumap' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_skumap', 'target' => 'ops_jst_sku_map', 'keys' => ['shop_id', 'shop_i_id', 'shop_sku_id'], 'watermark' => 'modifiedTime'],
|
||||
'operations_replica.rpt.ads_itemid_sales_cost_ratio_v2_di' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'ads_itemid_sales_cost_ratio_v2_di', 'target' => 'ops_item_sales_cost_ratio', 'keys' => ['stat_date', 'item_id'], 'watermark' => 'item_id_update'],
|
||||
'operations_replica.rpt.jst_returnAndRefundInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_returnAndRefundInfo', 'target' => 'ops_jst_return_refund_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
|
||||
'operations_replica.rpt.jst_returnAndRefundItem' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_returnAndRefundItem', 'target' => 'ops_jst_return_refund_item', 'keys' => ['Id']],
|
||||
'operations_replica.rpt.jst_returnAndRefundInfo' => [
|
||||
'source_key' => 'operations_replica',
|
||||
'schema' => 'rpt',
|
||||
'object' => 'jst_returnAndRefundInfo',
|
||||
'target' => 'ops_jst_return_refund_info',
|
||||
'keys' => ['Id'],
|
||||
'watermark' => 'updateTime',
|
||||
'indexes' => [
|
||||
['as_date', 'Id'],
|
||||
['o_id', 'Id'],
|
||||
['as_id', 'Id'],
|
||||
],
|
||||
],
|
||||
'operations_replica.rpt.jst_returnAndRefundItem' => [
|
||||
'source_key' => 'operations_replica',
|
||||
'schema' => 'rpt',
|
||||
'object' => 'jst_returnAndRefundItem',
|
||||
'target' => 'ops_jst_return_refund_item',
|
||||
'keys' => ['Id'],
|
||||
'indexes' => [
|
||||
['as_id', 'Id'],
|
||||
],
|
||||
],
|
||||
'operations_replica.rpt.jst_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_shopInfo', 'target' => 'ops_jst_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
|
||||
|
||||
// JianSun 旧业务库:营销账单、供应商应付、板材、封边条和库存。
|
||||
|
||||
@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
|
||||
class NonJingxiRefundMirrorRepository
|
||||
{
|
||||
public const INFO_DATASET = 'operations_replica.rpt.jst_returnAndRefundInfo';
|
||||
|
||||
public const ITEM_DATASET = 'operations_replica.rpt.jst_returnAndRefundItem';
|
||||
|
||||
private const AUXILIARY_DATASETS = [
|
||||
'operations_replica.rpt.jst_Orders',
|
||||
'operations_replica.rpt.jst_OrderItem',
|
||||
'operations_replica.rpt.jst_sku',
|
||||
'operations_replica.rpt.fys_shopInfo',
|
||||
];
|
||||
|
||||
private const REQUIRED_QUERY_INDEXES = [
|
||||
self::INFO_DATASET => [['as_date', 'Id'], ['o_id', 'Id'], ['as_id', 'Id']],
|
||||
self::ITEM_DATASET => [['as_id', 'Id']],
|
||||
'operations_replica.rpt.jst_Orders' => [['o_id', 'Id']],
|
||||
'operations_replica.rpt.jst_OrderItem' => [['o_id', 'Id']],
|
||||
];
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(
|
||||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||||
private readonly GenericBusinessMirrorRepository $mirror,
|
||||
) {
|
||||
$this->connection = $mirror->connection();
|
||||
}
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
|
||||
return $schema->hasTable('business_mirror_table_states')
|
||||
&& $schema->hasTable($this->dataset(self::INFO_DATASET)->targetTable())
|
||||
&& $schema->hasTable($this->dataset(self::ITEM_DATASET)->targetTable());
|
||||
}
|
||||
|
||||
public function ready(): bool
|
||||
{
|
||||
return $this->mirror->ready($this->dataset(self::INFO_DATASET))
|
||||
&& $this->mirror->ready($this->dataset(self::ITEM_DATASET));
|
||||
}
|
||||
|
||||
public function queryReady(): bool
|
||||
{
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
|
||||
try {
|
||||
foreach (self::REQUIRED_QUERY_INDEXES as $datasetKey => $requiredIndexes) {
|
||||
$table = $this->dataset($datasetKey)->targetTable();
|
||||
if (! $schema->hasTable($table)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$indexes = collect($schema->getIndexes($table))
|
||||
->map(static fn (array $index): array => array_map(
|
||||
'strtolower',
|
||||
(array) ($index['columns'] ?? []),
|
||||
));
|
||||
foreach ($requiredIndexes as $required) {
|
||||
if (! $indexes->contains(array_map('strtolower', $required))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string,array<string,mixed>|null> */
|
||||
public function states(): array
|
||||
{
|
||||
return [
|
||||
self::INFO_DATASET => $this->mirror->state($this->dataset(self::INFO_DATASET)),
|
||||
self::ITEM_DATASET => $this->mirror->state($this->dataset(self::ITEM_DATASET)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,array<string,mixed>|null>|null $states
|
||||
* @return array{code:string,last_succeeded_at:?string}
|
||||
*/
|
||||
public function syncStatus(?array $states = null, ?CarbonInterface $at = null): array
|
||||
{
|
||||
$states ??= $this->states();
|
||||
if (count(array_filter($states, 'is_array')) !== 2) {
|
||||
return ['code' => 'unavailable', 'last_succeeded_at' => null];
|
||||
}
|
||||
|
||||
$initialized = collect($states)->every(
|
||||
static fn (?array $state): bool => ($state['initial_completed_at'] ?? null) !== null
|
||||
);
|
||||
$statuses = collect($states)->pluck('status')->map('strval');
|
||||
$lastSucceededAt = collect($states)
|
||||
->pluck('last_succeeded_at')
|
||||
->filter()
|
||||
->sort()
|
||||
->first();
|
||||
|
||||
if (! $initialized) {
|
||||
$code = $statuses->contains('failed') ? 'initial_failed' : 'initializing';
|
||||
} elseif ($statuses->contains('failed')) {
|
||||
$code = 'failed';
|
||||
} elseif ($statuses->contains('running')) {
|
||||
$code = 'syncing';
|
||||
} else {
|
||||
$lastSucceeded = $lastSucceededAt ? Carbon::parse($lastSucceededAt) : null;
|
||||
$lagMinutes = $lastSucceeded === null
|
||||
? null
|
||||
: (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceeded->getTimestamp()) / 60);
|
||||
$code = $lagMinutes === null || $lagMinutes > 30 ? 'stale' : 'healthy';
|
||||
}
|
||||
|
||||
return ['code' => $code, 'last_succeeded_at' => $lastSucceededAt];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{from:string,to:string,order_no:string,shop_name:string,sku_id:string} $filters
|
||||
* @return array{data:list<object>,total:int}
|
||||
*/
|
||||
public function paginate(array $filters, int $page, int $perPage): array
|
||||
{
|
||||
if (! $this->ready() || ! $this->queryReady()) {
|
||||
throw new RuntimeException('business_mirror_dataset_not_ready');
|
||||
}
|
||||
|
||||
$page = max($page, 1);
|
||||
$perPage = min(max($perPage, 1), 100);
|
||||
$query = $this->baseQuery();
|
||||
$this->applyFilters($query, $filters);
|
||||
|
||||
$total = (clone $query)->count();
|
||||
$rows = $query
|
||||
->select([
|
||||
'i.Id as Id', 'r.as_date as refundDate', 'r.as_id', 'r.outer_as_id',
|
||||
'r.o_id', 'r.so_id', 'r.type', 'r.node', 'r.shop_type', 'r.shop_name',
|
||||
'r.shop_site as platform', 'r.refund as totalRefundMoney',
|
||||
'i.outer_oi_id', 'i.shop_sku_id', 'i.i_id', 'i.sku_id', 'i.qty',
|
||||
'i.shop_amount as detailRefundMoney', 'i.type as refundType',
|
||||
'i.r_qty as refundQty', 'r.receive_refund as RefundMoneyIncome',
|
||||
'r.buyer_apply_refund as AfSalesMoney', 'r.createTime', 'r.status',
|
||||
'r.shop_status', 'r.created', 'r.confirm_date',
|
||||
])
|
||||
->orderByDesc('r.as_date')
|
||||
->orderByDesc('r.Id')
|
||||
->orderByDesc('i.Id')
|
||||
->forPage($page, $perPage)
|
||||
->get();
|
||||
|
||||
$this->hydratePage($rows);
|
||||
|
||||
return ['data' => $rows->all(), 'total' => $total];
|
||||
}
|
||||
|
||||
private function baseQuery(): Builder
|
||||
{
|
||||
return $this->connection->table('ops_jst_return_refund_info as r')
|
||||
->join('ops_jst_return_refund_item as i', 'i.as_id', '=', 'r.as_id')
|
||||
->where(function (Builder $query): void {
|
||||
$query->whereNull('r.shop_site')
|
||||
->orWhere(function (Builder $platform): void {
|
||||
$platform->where('r.shop_site', 'not like', '%京喜%')
|
||||
->whereRaw('LOWER(r.shop_site) NOT LIKE ?', ['%jingxi%']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array{from:string,to:string,order_no:string,shop_name:string,sku_id:string} $filters */
|
||||
private function applyFilters(Builder $query, array $filters): void
|
||||
{
|
||||
$query->where('r.as_date', '>=', $filters['from'] . ' 00:00:00')
|
||||
->where('r.as_date', '<', Carbon::parse($filters['to'])->addDay()->format('Y-m-d 00:00:00'));
|
||||
|
||||
if ($filters['order_no'] !== '') {
|
||||
$needle = $this->contains($filters['order_no']);
|
||||
$query->where(function (Builder $order) use ($needle): void {
|
||||
$order->whereRaw("CAST(r.o_id AS CHAR) LIKE ? ESCAPE '!'", [$needle])
|
||||
->orWhereRaw("r.so_id LIKE ? ESCAPE '!'", [$needle])
|
||||
->orWhereRaw("r.outer_as_id LIKE ? ESCAPE '!'", [$needle]);
|
||||
});
|
||||
}
|
||||
|
||||
if ($filters['shop_name'] !== '') {
|
||||
$query->whereRaw("r.shop_name LIKE ? ESCAPE '!'", [$this->contains($filters['shop_name'])]);
|
||||
}
|
||||
|
||||
if ($filters['sku_id'] !== '') {
|
||||
$needle = $this->contains($filters['sku_id']);
|
||||
$query->where(function (Builder $sku) use ($needle): void {
|
||||
$sku->whereRaw("i.sku_id LIKE ? ESCAPE '!'", [$needle])
|
||||
->orWhereRaw("i.shop_sku_id LIKE ? ESCAPE '!'", [$needle])
|
||||
->orWhereRaw("i.i_id LIKE ? ESCAPE '!'", [$needle]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** @param \Illuminate\Support\Collection<int,object> $rows */
|
||||
private function hydratePage($rows): void
|
||||
{
|
||||
if ($rows->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$orderIds = $rows->pluck('o_id')->filter()->unique()->values()->all();
|
||||
$skuIds = $rows->pluck('sku_id')->filter()->unique()->values()->all();
|
||||
$shopNames = $rows->pluck('shop_name')->filter()->unique()->values()->all();
|
||||
$orderItems = $this->auxiliaryReady(self::AUXILIARY_DATASETS[1])
|
||||
? $this->connection->table('ops_jst_order_item')
|
||||
->whereIn('o_id', $orderIds)
|
||||
->whereIn('outer_oi_id', $rows->pluck('outer_oi_id')->filter()->unique()->all())
|
||||
->get(['o_id', 'outer_oi_id', 'shop_i_id', 'shop_sku_id', 'i_id', 'sku_id', 'buyer_paid_amount'])
|
||||
->keyBy(static fn (object $row): string => $row->o_id . '|' . $row->outer_oi_id)
|
||||
: collect();
|
||||
$orders = $this->auxiliaryReady(self::AUXILIARY_DATASETS[0])
|
||||
? $this->connection->table('ops_jst_orders')->whereIn('o_id', $orderIds)
|
||||
->orderByDesc('Id')->get(['o_id', 'receiver_zip'])->unique('o_id')->keyBy('o_id')
|
||||
: collect();
|
||||
$skus = $this->auxiliaryReady(self::AUXILIARY_DATASETS[2])
|
||||
? $this->connection->table('ops_jst_sku')->whereIn('sku_id', $skuIds)
|
||||
->where('sku_type', 'normal')->orderByDesc('autoid')->get(['sku_id', 'cost_price'])
|
||||
->unique('sku_id')->keyBy('sku_id')
|
||||
: collect();
|
||||
$shops = $this->auxiliaryReady(self::AUXILIARY_DATASETS[3])
|
||||
? $this->connection->table('ops_fys_shop_info')->whereIn('erpShopName', $shopNames)
|
||||
->orderByDesc('Id')->get(['erpShopName', 'responsibleDepartment'])
|
||||
->unique('erpShopName')->keyBy('erpShopName')
|
||||
: collect();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$orderItem = $orderItems->get($row->o_id . '|' . $row->outer_oi_id);
|
||||
$skuId = $row->sku_id ?: ($orderItem->sku_id ?? null);
|
||||
$unitCost = isset($skus[$skuId]) ? (float) $skus[$skuId]->cost_price : null;
|
||||
$refundQuantity = $row->refundQty === null ? null : (float) $row->refundQty;
|
||||
|
||||
$row->isZC = str_contains((string) $row->shop_type, '自营') ? '是' : '否';
|
||||
$row->responsibleDepartment = $shops[$row->shop_name]->responsibleDepartment ?? null;
|
||||
$row->originPostcode = $orders[$row->o_id]->receiver_zip ?? null;
|
||||
$row->shop_i_id = $orderItem->shop_i_id ?? null;
|
||||
$row->shop_sku_id = $row->shop_sku_id ?: ($orderItem->shop_sku_id ?? null);
|
||||
$row->i_id = $row->i_id ?: ($orderItem->i_id ?? null);
|
||||
$row->sku_id = $skuId;
|
||||
$row->buyer_paid_amount = $orderItem->buyer_paid_amount ?? null;
|
||||
$row->unitCostGoods = $unitCost;
|
||||
$row->refundCostGoods = $unitCost === null || $refundQuantity === null
|
||||
? null
|
||||
: $unitCost * $refundQuantity;
|
||||
unset($row->shop_type, $row->outer_oi_id);
|
||||
}
|
||||
}
|
||||
|
||||
private function auxiliaryReady(string $datasetKey): bool
|
||||
{
|
||||
try {
|
||||
return $this->mirror->ready($this->dataset($datasetKey));
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function contains(string $value): string
|
||||
{
|
||||
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
|
||||
}
|
||||
|
||||
private function dataset(string $key): BusinessMirrorDataset
|
||||
{
|
||||
return $this->datasets->get($key);
|
||||
}
|
||||
}
|
||||
@ -13,30 +13,41 @@
|
||||
['label' => '店铺名称', 'field' => 'shop_name'],
|
||||
['label' => '平台', 'field' => 'platform'],
|
||||
['label' => '责任部门', 'field' => 'responsibleDepartment'],
|
||||
['label' => '退款总金额', 'field' => 'totalRefundMoney'],
|
||||
['label' => '退款总金额', 'field' => 'totalRefundMoney', 'number' => true],
|
||||
['label' => '原始邮编', 'field' => 'originPostcode'],
|
||||
['label' => '店铺商品ID', 'field' => 'shop_i_id'],
|
||||
['label' => '店铺SKU编码', 'field' => 'shop_sku_id'],
|
||||
['label' => '商品ID', 'field' => 'i_id'],
|
||||
['label' => 'SKU编码', 'field' => 'sku_id'],
|
||||
['label' => '买家实付金额', 'field' => 'buyer_paid_amount'],
|
||||
['label' => '数量', 'field' => 'qty'],
|
||||
['label' => '商品单位成本', 'field' => 'unitCostGoods'],
|
||||
['label' => '明细退款金额', 'field' => 'detailRefundMoney'],
|
||||
['label' => '买家实付金额', 'field' => 'buyer_paid_amount', 'number' => true],
|
||||
['label' => '数量', 'field' => 'qty', 'number' => true],
|
||||
['label' => '商品单位成本', 'field' => 'unitCostGoods', 'number' => true],
|
||||
['label' => '明细退款金额', 'field' => 'detailRefundMoney', 'number' => true],
|
||||
['label' => '退款类型', 'field' => 'refundType'],
|
||||
['label' => '退款数量', 'field' => 'refundQty'],
|
||||
['label' => '退款商品成本', 'field' => 'refundCostGoods'],
|
||||
['label' => '退款回款金额', 'field' => 'RefundMoneyIncome'],
|
||||
['label' => '售后金额', 'field' => 'AfSalesMoney'],
|
||||
['label' => '退款数量', 'field' => 'refundQty', 'number' => true],
|
||||
['label' => '退款商品成本', 'field' => 'refundCostGoods', 'number' => true],
|
||||
['label' => '退款回款金额', 'field' => 'RefundMoneyIncome', 'number' => true],
|
||||
['label' => '售后金额', 'field' => 'AfSalesMoney', 'number' => true],
|
||||
['label' => '创建时间', 'field' => 'createTime'],
|
||||
['label' => '状态', 'field' => 'status'],
|
||||
['label' => '店铺状态', 'field' => 'shop_status'],
|
||||
['label' => '创建记录/创建时间', 'field' => 'created'],
|
||||
['label' => '确认日期', 'field' => 'confirm_date'],
|
||||
];
|
||||
$syncCode = $refundSyncStatus['code'] ?? 'unavailable';
|
||||
$syncLabel = match ($syncCode) {
|
||||
'healthy' => '业务镜像已同步',
|
||||
'syncing' => '业务镜像同步中,当前显示最近成功数据',
|
||||
'failed' => '本轮同步失败,当前显示最近成功数据',
|
||||
'stale' => '业务镜像同步时间较早,当前显示最近成功数据',
|
||||
'initializing' => '业务镜像正在执行首次同步',
|
||||
'initial_failed' => '业务镜像首次同步失败',
|
||||
default => '业务镜像尚未建立',
|
||||
};
|
||||
$lastSucceededAt = $refundSyncStatus['last_succeeded_at'] ?? null;
|
||||
@endphp
|
||||
|
||||
<div class="upstream-data-page upstream-data-page--spaced">
|
||||
<div class="upstream-data-page upstream-data-page--spaced" data-refund-page="non-jingxi">
|
||||
@include('business.pages.partials.upstream-data-styles')
|
||||
|
||||
<style>
|
||||
@ -46,8 +57,83 @@
|
||||
color: var(--vip-content-muted, #595959);
|
||||
font-weight: 400;
|
||||
}
|
||||
.refund-data-sync {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 1.5rem 0 0;
|
||||
color: var(--vip-content-muted, #595959);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.refund-data-sync .material-icons-outlined {
|
||||
color: var(--vip-content-accent-dark, #856632);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.upstream-data-alert--warning {
|
||||
border-color: rgba(147, 95, 7, 0.35);
|
||||
background: rgba(147, 95, 7, 0.06);
|
||||
color: var(--vip-warning, #935f07);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="refund-data-sync" role="status" data-sync-state="{{ $syncCode }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">sync</span>
|
||||
<span>{{ $syncLabel }}</span>
|
||||
@if ($lastSucceededAt)
|
||||
<span>· 最近成功 {{ $lastSucceededAt }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($refundError)
|
||||
<div class="upstream-data-alert" role="alert">
|
||||
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
|
||||
<span>{{ $refundError }}</span>
|
||||
</div>
|
||||
@elseif ($refundIndexPending)
|
||||
<div class="upstream-data-alert upstream-data-alert--warning" role="status">
|
||||
<span class="material-icons-outlined" aria-hidden="true">hourglass_top</span>
|
||||
<span>页面查询索引正在准备,生产同步轮转完成后将自动显示数据。</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form
|
||||
class="upstream-data-toolbar"
|
||||
method="GET"
|
||||
action="{{ route('business.pages.show', ['section' => 'refunds', 'page' => 'non-jingxi-refund-data']) }}"
|
||||
data-filter-contract="non-jingxi-refund-data.filters"
|
||||
>
|
||||
<div class="upstream-data-field">
|
||||
<label for="non-jingxi-refund-from">退款开始日期</label>
|
||||
<input id="non-jingxi-refund-from" type="date" name="from" value="{{ $filters['from'] }}">
|
||||
</div>
|
||||
<div class="upstream-data-field">
|
||||
<label for="non-jingxi-refund-to">退款结束日期</label>
|
||||
<input id="non-jingxi-refund-to" type="date" name="to" value="{{ $filters['to'] }}">
|
||||
</div>
|
||||
<div class="upstream-data-field">
|
||||
<label for="non-jingxi-refund-order">订单号</label>
|
||||
<input id="non-jingxi-refund-order" type="search" name="order_no" value="{{ $filters['order_no'] }}" placeholder="订单或售后单号">
|
||||
</div>
|
||||
<div class="upstream-data-field">
|
||||
<label for="non-jingxi-refund-shop">店铺名称</label>
|
||||
<input id="non-jingxi-refund-shop" type="search" name="shop_name" value="{{ $filters['shop_name'] }}" placeholder="店铺名称">
|
||||
</div>
|
||||
<div class="upstream-data-field">
|
||||
<label for="non-jingxi-refund-sku">商品编码</label>
|
||||
<input id="non-jingxi-refund-sku" type="search" name="sku_id" value="{{ $filters['sku_id'] }}" placeholder="商品或 SKU 编码">
|
||||
</div>
|
||||
<div class="upstream-data-actions">
|
||||
<button class="upstream-data-button upstream-data-button--primary" type="submit">
|
||||
<span class="material-icons-outlined" aria-hidden="true">search</span>
|
||||
<span>查询</span>
|
||||
</button>
|
||||
<a class="upstream-data-button" href="{{ route('business.pages.show', ['section' => 'refunds', 'page' => 'non-jingxi-refund-data']) }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
|
||||
<span>重置</span>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="upstream-data-table-panel">
|
||||
<div class="upstream-data-table-scroll">
|
||||
<table class="upstream-data-table refund-data-table refund-data-table--wide" aria-label="非京喜退款数据">
|
||||
@ -64,15 +150,58 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="is-empty">
|
||||
<td colspan="{{ count($columns) }}">暂无数据</td>
|
||||
</tr>
|
||||
@forelse ($rows as $row)
|
||||
<tr>
|
||||
@foreach ($columns as $column)
|
||||
@php
|
||||
$field = $column['field'];
|
||||
$value = $field === '' ? null : data_get($row, $field);
|
||||
$isNumber = (bool) ($column['number'] ?? false);
|
||||
@endphp
|
||||
<td @class(['is-number' => $isNumber])>
|
||||
@if ($isNumber && $value !== null && $value !== '')
|
||||
{{ number_format((float) $value, 2) }}
|
||||
@else
|
||||
{{ $value === null || $value === '' ? '-' : $value }}
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
@empty
|
||||
<tr class="is-empty">
|
||||
<td colspan="{{ count($columns) }}">
|
||||
@if ($refundPending)
|
||||
首次同步尚未完成,完成后将自动显示数据。
|
||||
@elseif ($refundIndexPending)
|
||||
页面查询索引尚未就绪,当前不会执行高压力查询。
|
||||
@elseif ($refundError)
|
||||
数据暂时无法读取。
|
||||
@else
|
||||
当前筛选范围暂无数据。
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="upstream-data-pagination">
|
||||
<span>共 0 条记录</span>
|
||||
<span>第 1 / 1 页</span>
|
||||
<span>共 {{ number_format($rows->total()) }} 条记录</span>
|
||||
<div class="upstream-data-pagination__controls" aria-label="非京喜退款数据分页">
|
||||
<a
|
||||
@class(['upstream-data-button', 'is-disabled' => $rows->onFirstPage()])
|
||||
href="{{ $rows->previousPageUrl() ?: '#' }}"
|
||||
aria-label="上一页"
|
||||
@if ($rows->onFirstPage()) aria-disabled="true" @endif
|
||||
><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
|
||||
<span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页</span>
|
||||
<a
|
||||
@class(['upstream-data-button', 'is-disabled' => !$rows->hasMorePages()])
|
||||
href="{{ $rows->nextPageUrl() ?: '#' }}"
|
||||
aria-label="下一页"
|
||||
@if (!$rows->hasMorePages()) aria-disabled="true" @endif
|
||||
><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
254
tests/Feature/Business/NonJingxiRefundPageTest.php
Normal file
254
tests/Feature/Business/NonJingxiRefundPageTest.php
Normal file
@ -0,0 +1,254 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class NonJingxiRefundPageTest extends FeatureTestCase
|
||||
{
|
||||
protected function grantAdminPermission(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_page_renders_mirror_rows_second_page_and_filters(): void
|
||||
{
|
||||
$this->grantAdminPermission();
|
||||
setting()->set('default.list_limit', 25);
|
||||
|
||||
$states = [
|
||||
NonJingxiRefundMirrorRepository::INFO_DATASET => ['status' => 'running', 'initial_completed_at' => '2026-08-15 00:00:00'],
|
||||
NonJingxiRefundMirrorRepository::ITEM_DATASET => ['status' => 'running', 'initial_completed_at' => '2026-08-15 00:00:00'],
|
||||
];
|
||||
$repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('states')->once()->andReturn($states);
|
||||
$repository->shouldReceive('syncStatus')->once()->with($states)->andReturn([
|
||||
'code' => 'syncing',
|
||||
'last_succeeded_at' => '2026-08-21 12:00:00',
|
||||
]);
|
||||
$repository->shouldReceive('ready')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('queryReady')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('paginate')->once()->withArgs(
|
||||
static fn (array $filters, int $page, int $limit): bool =>
|
||||
$filters === [
|
||||
'from' => '2026-08-01',
|
||||
'to' => '2026-08-21',
|
||||
'order_no' => 'ORDER-1',
|
||||
'shop_name' => '测试店',
|
||||
'sku_id' => 'SKU-1',
|
||||
] && $page === 2 && $limit === 25
|
||||
)->andReturn([
|
||||
'data' => [(object) [
|
||||
'Id' => 102,
|
||||
'refundDate' => '2026-08-20 10:00:00',
|
||||
'as_id' => 2002,
|
||||
'outer_as_id' => 'AS-2002',
|
||||
'o_id' => 3002,
|
||||
'so_id' => 'ORDER-1',
|
||||
'type' => '退款',
|
||||
'node' => '完成',
|
||||
'isZC' => '是',
|
||||
'shop_name' => '测试店',
|
||||
'platform' => '天猫',
|
||||
'responsibleDepartment' => '电商部',
|
||||
'totalRefundMoney' => 99.5,
|
||||
'originPostcode' => 'ZIP',
|
||||
'shop_i_id' => 'ITEM-1',
|
||||
'shop_sku_id' => 'SHOP-SKU-1',
|
||||
'i_id' => 'STYLE-1',
|
||||
'sku_id' => 'SKU-1',
|
||||
'buyer_paid_amount' => 120,
|
||||
'qty' => 1,
|
||||
'unitCostGoods' => 50,
|
||||
'detailRefundMoney' => 99.5,
|
||||
'refundType' => '退货退款',
|
||||
'refundQty' => 1,
|
||||
'refundCostGoods' => 50,
|
||||
'RefundMoneyIncome' => 99.5,
|
||||
'AfSalesMoney' => 99.5,
|
||||
'createTime' => '2026-08-20 09:00:00',
|
||||
'status' => '完成',
|
||||
'shop_status' => '完成',
|
||||
'created' => '2026-08-20 09:00:00',
|
||||
'confirm_date' => '2026-08-20 10:00:00',
|
||||
]],
|
||||
'total' => 26,
|
||||
]);
|
||||
$this->app->instance(NonJingxiRefundMirrorRepository::class, $repository);
|
||||
|
||||
$url = route('business.pages.show', [
|
||||
'company_id' => $this->company->id,
|
||||
'section' => 'refunds',
|
||||
'page' => 'non-jingxi-refund-data',
|
||||
]) . '?' . http_build_query([
|
||||
'from' => '2026-08-01', 'to' => '2026-08-21', 'order_no' => 'ORDER-1',
|
||||
'shop_name' => '测试店', 'sku_id' => 'SKU-1', 'page' => 2,
|
||||
]);
|
||||
|
||||
$this->loginAs()->get($url)
|
||||
->assertOk()
|
||||
->assertSee('data-filter-contract="non-jingxi-refund-data.filters"', false)
|
||||
->assertSee('data-sync-state="syncing"', false)
|
||||
->assertSeeText('业务镜像同步中,当前显示最近成功数据')
|
||||
->assertSeeText('AS-2002')
|
||||
->assertSeeText('SKU-1')
|
||||
->assertSeeText('99.50')
|
||||
->assertSeeText('共 26 条记录')
|
||||
->assertSeeText('第 2 / 2 页')
|
||||
->assertSee('value="2026-08-01"', false)
|
||||
->assertSee('value="ORDER-1"', false);
|
||||
}
|
||||
|
||||
public function test_page_does_not_report_empty_data_before_initial_sync_finishes(): void
|
||||
{
|
||||
$this->grantAdminPermission();
|
||||
|
||||
$states = [
|
||||
NonJingxiRefundMirrorRepository::INFO_DATASET => ['status' => 'running'],
|
||||
NonJingxiRefundMirrorRepository::ITEM_DATASET => ['status' => 'running'],
|
||||
];
|
||||
$repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('states')->once()->andReturn($states);
|
||||
$repository->shouldReceive('syncStatus')->once()->with($states)->andReturn([
|
||||
'code' => 'initializing',
|
||||
'last_succeeded_at' => null,
|
||||
]);
|
||||
$repository->shouldReceive('ready')->once()->andReturnFalse();
|
||||
$repository->shouldNotReceive('queryReady');
|
||||
$repository->shouldNotReceive('paginate');
|
||||
$this->app->instance(NonJingxiRefundMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'refunds',
|
||||
'page' => 'non-jingxi-refund-data',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('业务镜像正在执行首次同步')
|
||||
->assertSeeText('首次同步尚未完成,完成后将自动显示数据。')
|
||||
->assertDontSeeText('当前筛选范围暂无数据。');
|
||||
}
|
||||
|
||||
public function test_page_shows_a_real_empty_filter_result(): void
|
||||
{
|
||||
$this->grantAdminPermission();
|
||||
|
||||
$states = [
|
||||
NonJingxiRefundMirrorRepository::INFO_DATASET => ['status' => 'succeeded', 'initial_completed_at' => '2026-08-15 00:00:00'],
|
||||
NonJingxiRefundMirrorRepository::ITEM_DATASET => ['status' => 'succeeded', 'initial_completed_at' => '2026-08-15 00:00:00'],
|
||||
];
|
||||
$repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('states')->once()->andReturn($states);
|
||||
$repository->shouldReceive('syncStatus')->once()->andReturn(['code' => 'healthy', 'last_succeeded_at' => '2026-08-21 12:00:00']);
|
||||
$repository->shouldReceive('ready')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('queryReady')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('paginate')->once()->andReturn(['data' => [], 'total' => 0]);
|
||||
$this->app->instance(NonJingxiRefundMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'refunds',
|
||||
'page' => 'non-jingxi-refund-data',
|
||||
'sku_id' => 'NOT-FOUND',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('当前筛选范围暂无数据。')
|
||||
->assertSeeText('共 0 条记录');
|
||||
}
|
||||
|
||||
public function test_page_does_not_run_the_query_before_required_indexes_exist(): void
|
||||
{
|
||||
$this->grantAdminPermission();
|
||||
|
||||
$states = [
|
||||
NonJingxiRefundMirrorRepository::INFO_DATASET => ['status' => 'running', 'initial_completed_at' => '2026-08-15 00:00:00'],
|
||||
NonJingxiRefundMirrorRepository::ITEM_DATASET => ['status' => 'running', 'initial_completed_at' => '2026-08-15 00:00:00'],
|
||||
];
|
||||
$repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('states')->once()->andReturn($states);
|
||||
$repository->shouldReceive('syncStatus')->once()->with($states)->andReturn([
|
||||
'code' => 'syncing',
|
||||
'last_succeeded_at' => '2026-08-17 10:05:07',
|
||||
]);
|
||||
$repository->shouldReceive('ready')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('queryReady')->once()->andReturnFalse();
|
||||
$repository->shouldNotReceive('paginate');
|
||||
$this->app->instance(NonJingxiRefundMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs();
|
||||
$url = route('business.pages.show', [
|
||||
'section' => 'refunds',
|
||||
'page' => 'non-jingxi-refund-data',
|
||||
]).'?page=2';
|
||||
|
||||
$this->get($url)
|
||||
->assertOk()
|
||||
->assertSee('upstream-data-alert--warning', false)
|
||||
->assertSeeText('页面查询索引正在准备,生产同步轮转完成后将自动显示数据。')
|
||||
->assertSeeText('页面查询索引尚未就绪,当前不会执行高压力查询。')
|
||||
->assertSeeText('第 1 / 1 页')
|
||||
->assertDontSeeText('第 2 / 1 页')
|
||||
->assertDontSeeText('当前筛选范围暂无数据。');
|
||||
}
|
||||
|
||||
public function test_page_replaces_invalid_dates_with_the_default_range(): void
|
||||
{
|
||||
$this->grantAdminPermission();
|
||||
$this->travelTo('2026-08-21 12:00:00');
|
||||
|
||||
$states = [
|
||||
NonJingxiRefundMirrorRepository::INFO_DATASET => ['status' => 'succeeded', 'initial_completed_at' => '2026-08-15 00:00:00'],
|
||||
NonJingxiRefundMirrorRepository::ITEM_DATASET => ['status' => 'succeeded', 'initial_completed_at' => '2026-08-15 00:00:00'],
|
||||
];
|
||||
$repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('states')->once()->andReturn($states);
|
||||
$repository->shouldReceive('syncStatus')->once()->with($states)->andReturn([
|
||||
'code' => 'healthy',
|
||||
'last_succeeded_at' => '2026-08-21 12:00:00',
|
||||
]);
|
||||
$repository->shouldReceive('ready')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('queryReady')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('paginate')->once()->withArgs(
|
||||
static fn (array $filters, int $page, int $limit): bool =>
|
||||
$filters['from'] === '2026-08-15'
|
||||
&& $filters['to'] === '2026-08-21'
|
||||
&& $page === 1
|
||||
&& $limit === 25
|
||||
)->andReturn(['data' => [], 'total' => 0]);
|
||||
$this->app->instance(NonJingxiRefundMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'refunds',
|
||||
'page' => 'non-jingxi-refund-data',
|
||||
'from' => '2026-02-31',
|
||||
'to' => 'not-a-date',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSee('value="2026-08-15"', false)
|
||||
->assertSee('value="2026-08-21"', false)
|
||||
->assertDontSee('非京喜退款数据暂时无法读取,请稍后重试。');
|
||||
}
|
||||
|
||||
public function test_page_rejects_users_without_sidebar_permission(): void
|
||||
{
|
||||
$subject = 'user_' . $this->user->id;
|
||||
setting()->set('system.sidebar_permissions', json_encode([
|
||||
'configured' => [$subject],
|
||||
'subjects' => [
|
||||
$subject => ['company-management/basic-information'],
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$this->withExceptionHandling()->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'refunds',
|
||||
'page' => 'non-jingxi-refund-data',
|
||||
]))->assertForbidden();
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use Modules\EcommerceFinance\Models\AlipayBill;
|
||||
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class PagesTest extends FeatureTestCase
|
||||
@ -150,6 +151,15 @@ class PagesTest extends FeatureTestCase
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
|
||||
$repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnFalse();
|
||||
$repository->shouldNotReceive('states');
|
||||
$repository->shouldNotReceive('syncStatus');
|
||||
$repository->shouldNotReceive('ready');
|
||||
$repository->shouldNotReceive('queryReady');
|
||||
$repository->shouldNotReceive('paginate');
|
||||
$this->app->instance(NonJingxiRefundMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', [
|
||||
'section' => 'refunds',
|
||||
@ -177,7 +187,8 @@ class PagesTest extends FeatureTestCase
|
||||
->assertOk()
|
||||
->assertSeeText('非京喜退款数据')
|
||||
->assertSee('aria-label="非京喜退款数据"', false)
|
||||
->assertSeeText('暂无数据')
|
||||
->assertSeeText('共享业务镜像尚未建立')
|
||||
->assertSeeText('数据暂时无法读取')
|
||||
->assertSee('class="upstream-data-table refund-data-table refund-data-table--wide"', false)
|
||||
->assertSee('data-header-row="labels"', false)
|
||||
->assertSee('data-header-row="fields"', false)
|
||||
@ -219,10 +230,15 @@ class PagesTest extends FeatureTestCase
|
||||
'AfSalesMoney', 'createTime', 'status', 'shop_status', 'created', 'confirm_date',
|
||||
], $headers->get('fields'));
|
||||
|
||||
$this->withExceptionHandling()->get(route('business.pages.show', [
|
||||
$this->withExceptionHandling()->loginAs()->get(route('business.pages.show', [
|
||||
'company_id' => $this->company->id,
|
||||
'section' => 'refunds',
|
||||
'page' => 'unknown-refund-page',
|
||||
]))->assertNotFound();
|
||||
]))->assertRedirect(route('business.pages.show', [
|
||||
'company_id' => $this->company->id,
|
||||
'section' => 'refunds',
|
||||
'page' => 'non-jingxi-refund-data',
|
||||
]));
|
||||
}
|
||||
|
||||
public function testPaperMaterialPagesRenderAsSeparateTables()
|
||||
|
||||
@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NonJingxiRefundMirrorRepositoryTest extends TestCase
|
||||
{
|
||||
private NonJingxiRefundMirrorRepository $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' => $this->definitions(),
|
||||
]);
|
||||
DB::purge('business_mirror');
|
||||
$this->createTables();
|
||||
$this->markReady();
|
||||
|
||||
$generic = new GenericBusinessMirrorRepository(app('db'));
|
||||
$this->repository = new NonJingxiRefundMirrorRepository(
|
||||
new BusinessMirrorDatasetRegistry(),
|
||||
$generic,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_ready_mirror_supports_second_page_filters_and_current_page_enrichment(): void
|
||||
{
|
||||
$db = DB::connection('business_mirror');
|
||||
$db->table('ops_jst_return_refund_info')->insert([
|
||||
$this->refundInfo(1, 101, '2026-08-19 10:00:00', '天猫', '一号店'),
|
||||
$this->refundInfo(2, 102, '2026-08-20 10:00:00', '抖音', '二号店'),
|
||||
$this->refundInfo(3, 103, '2026-08-21 10:00:00', 'jingxi', '京喜店'),
|
||||
]);
|
||||
$db->table('ops_jst_return_refund_item')->insert([
|
||||
$this->refundItem(11, 101, 'SKU-1', 'SUB-1'),
|
||||
$this->refundItem(12, 102, 'SKU-2', 'SUB-2'),
|
||||
$this->refundItem(13, 103, 'SKU-JX', 'SUB-JX'),
|
||||
]);
|
||||
$db->table('ops_jst_orders')->insert([
|
||||
['Id' => 1, 'o_id' => 8901, 'receiver_zip' => 'ZIP-1'],
|
||||
['Id' => 2, 'o_id' => 8902, 'receiver_zip' => 'ZIP-2'],
|
||||
]);
|
||||
$db->table('ops_jst_order_item')->insert([
|
||||
['Id' => 1, 'o_id' => 8901, 'outer_oi_id' => 'SUB-1', 'shop_i_id' => 'ITEM-1', 'shop_sku_id' => 'SHOP-SKU-1', 'i_id' => 'STYLE-1', 'sku_id' => 'SKU-1', 'buyer_paid_amount' => 88],
|
||||
['Id' => 2, 'o_id' => 8902, 'outer_oi_id' => 'SUB-2', 'shop_i_id' => 'ITEM-2', 'shop_sku_id' => 'SHOP-SKU-2', 'i_id' => 'STYLE-2', 'sku_id' => 'SKU-2', 'buyer_paid_amount' => 99],
|
||||
]);
|
||||
$db->table('ops_jst_sku')->insert([
|
||||
['autoid' => 1, 'sku_id' => 'SKU-1', 'sku_type' => 'normal', 'cost_price' => 20],
|
||||
['autoid' => 2, 'sku_id' => 'SKU-2', 'sku_type' => 'normal', 'cost_price' => 30],
|
||||
]);
|
||||
$db->table('ops_fys_shop_info')->insert([
|
||||
['Id' => 1, 'erpShopName' => '一号店', 'responsibleDepartment' => '部门甲'],
|
||||
['Id' => 2, 'erpShopName' => '二号店', 'responsibleDepartment' => '部门乙'],
|
||||
]);
|
||||
|
||||
$filters = ['from' => '2026-08-18', 'to' => '2026-08-21', 'order_no' => '', 'shop_name' => '', 'sku_id' => ''];
|
||||
$page2 = $this->repository->paginate($filters, 2, 1);
|
||||
|
||||
$this->assertSame(2, $page2['total']);
|
||||
$this->assertSame(11, $page2['data'][0]->Id);
|
||||
$this->assertSame('ZIP-1', $page2['data'][0]->originPostcode);
|
||||
$this->assertSame('ITEM-1', $page2['data'][0]->shop_i_id);
|
||||
$this->assertSame(88, $page2['data'][0]->buyer_paid_amount);
|
||||
$this->assertSame(20.0, $page2['data'][0]->unitCostGoods);
|
||||
$this->assertSame(40.0, $page2['data'][0]->refundCostGoods);
|
||||
$this->assertSame('部门甲', $page2['data'][0]->responsibleDepartment);
|
||||
|
||||
$filters['shop_name'] = '二号';
|
||||
$this->assertSame(1, $this->repository->paginate($filters, 1, 25)['total']);
|
||||
$filters['shop_name'] = '';
|
||||
$filters['shop_name'] = '%';
|
||||
$this->assertSame(0, $this->repository->paginate($filters, 1, 25)['total']);
|
||||
$filters['shop_name'] = '';
|
||||
$filters['sku_id'] = 'SKU-1';
|
||||
$this->assertSame(1, $this->repository->paginate($filters, 1, 25)['total']);
|
||||
$filters['sku_id'] = '';
|
||||
$filters['order_no'] = '8902';
|
||||
$this->assertSame(1, $this->repository->paginate($filters, 1, 25)['total']);
|
||||
}
|
||||
|
||||
public function test_sync_status_distinguishes_rotation_from_initial_sync(): void
|
||||
{
|
||||
$states = $this->repository->states();
|
||||
foreach ($states as &$state) {
|
||||
$state['status'] = 'running';
|
||||
$state['last_succeeded_at'] = '2026-08-21 12:00:00';
|
||||
}
|
||||
unset($state);
|
||||
|
||||
$this->assertSame('syncing', $this->repository->syncStatus(
|
||||
$states,
|
||||
Carbon::parse('2026-08-21 12:05:00'),
|
||||
)['code']);
|
||||
|
||||
$states[NonJingxiRefundMirrorRepository::INFO_DATASET]['initial_completed_at'] = null;
|
||||
$this->assertSame('initializing', $this->repository->syncStatus($states)['code']);
|
||||
}
|
||||
|
||||
public function test_query_is_blocked_when_a_required_lookup_index_is_missing(): void
|
||||
{
|
||||
Schema::connection('business_mirror')->table(
|
||||
'ops_jst_return_refund_info',
|
||||
static fn (Blueprint $table) => $table->dropIndex(['as_date', 'Id']),
|
||||
);
|
||||
|
||||
$this->assertFalse($this->repository->queryReady());
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('business_mirror_dataset_not_ready');
|
||||
$this->repository->paginate(
|
||||
['from' => '2026-08-18', 'to' => '2026-08-21', 'order_no' => '', 'shop_name' => '', 'sku_id' => ''],
|
||||
1,
|
||||
25,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string,array<string,mixed>> */
|
||||
private function definitions(): array
|
||||
{
|
||||
return [
|
||||
NonJingxiRefundMirrorRepository::INFO_DATASET => $this->definition('jst_returnAndRefundInfo', 'ops_jst_return_refund_info'),
|
||||
NonJingxiRefundMirrorRepository::ITEM_DATASET => $this->definition('jst_returnAndRefundItem', 'ops_jst_return_refund_item'),
|
||||
'operations_replica.rpt.jst_Orders' => $this->definition('jst_Orders', 'ops_jst_orders'),
|
||||
'operations_replica.rpt.jst_OrderItem' => $this->definition('jst_OrderItem', 'ops_jst_order_item'),
|
||||
'operations_replica.rpt.jst_sku' => $this->definition('jst_sku', 'ops_jst_sku'),
|
||||
'operations_replica.rpt.fys_shopInfo' => $this->definition('fys_shopInfo', 'ops_fys_shop_info'),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function definition(string $object, string $target): array
|
||||
{
|
||||
return ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => $object, 'target' => $target, 'keys' => ['Id']];
|
||||
}
|
||||
|
||||
private function createTables(): void
|
||||
{
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->string('status')->default('succeeded');
|
||||
$table->dateTime('initial_completed_at')->nullable();
|
||||
$table->dateTime('last_succeeded_at')->nullable();
|
||||
});
|
||||
$schema->create('ops_jst_return_refund_info', function (Blueprint $table): void {
|
||||
$table->integer('Id'); $table->integer('as_id'); $table->dateTime('as_date'); $table->string('outer_as_id')->nullable();
|
||||
$table->integer('o_id'); $table->string('so_id')->nullable(); $table->string('type')->nullable(); $table->string('node')->nullable();
|
||||
$table->string('shop_type')->nullable(); $table->string('shop_name')->nullable(); $table->string('shop_site')->nullable();
|
||||
$table->decimal('refund', 18, 2)->nullable(); $table->decimal('receive_refund', 18, 2)->nullable();
|
||||
$table->decimal('buyer_apply_refund', 18, 2)->nullable(); $table->dateTime('createTime')->nullable();
|
||||
$table->string('status')->nullable(); $table->string('shop_status')->nullable(); $table->dateTime('created')->nullable();
|
||||
$table->dateTime('confirm_date')->nullable();
|
||||
$table->index(['as_date', 'Id']);
|
||||
$table->index(['o_id', 'Id']);
|
||||
$table->index(['as_id', 'Id']);
|
||||
});
|
||||
$schema->create('ops_jst_return_refund_item', function (Blueprint $table): void {
|
||||
$table->integer('Id'); $table->integer('as_id'); $table->string('outer_oi_id')->nullable();
|
||||
$table->string('shop_sku_id')->nullable(); $table->string('i_id')->nullable(); $table->string('sku_id')->nullable();
|
||||
$table->integer('qty')->nullable(); $table->decimal('shop_amount', 18, 2)->nullable();
|
||||
$table->string('type')->nullable(); $table->integer('r_qty')->nullable();
|
||||
$table->index(['as_id', 'Id']);
|
||||
});
|
||||
$schema->create('ops_jst_orders', function (Blueprint $table): void {
|
||||
$table->integer('Id'); $table->integer('o_id'); $table->string('receiver_zip')->nullable();
|
||||
$table->index(['o_id', 'Id']);
|
||||
});
|
||||
$schema->create('ops_jst_order_item', function (Blueprint $table): void {
|
||||
$table->integer('Id'); $table->integer('o_id'); $table->string('outer_oi_id');
|
||||
$table->string('shop_i_id')->nullable(); $table->string('shop_sku_id')->nullable(); $table->string('i_id')->nullable();
|
||||
$table->string('sku_id')->nullable(); $table->decimal('buyer_paid_amount', 18, 2)->nullable();
|
||||
$table->index(['o_id', 'Id']);
|
||||
});
|
||||
$schema->create('ops_jst_sku', function (Blueprint $table): void {
|
||||
$table->integer('autoid'); $table->string('sku_id'); $table->string('sku_type'); $table->decimal('cost_price', 18, 2)->nullable();
|
||||
});
|
||||
$schema->create('ops_fys_shop_info', function (Blueprint $table): void {
|
||||
$table->integer('Id'); $table->string('erpShopName'); $table->string('responsibleDepartment')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
private function markReady(): void
|
||||
{
|
||||
foreach (array_keys($this->definitions()) as $key) {
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
|
||||
'dataset_key' => $key,
|
||||
'status' => 'succeeded',
|
||||
'initial_completed_at' => '2026-08-15 00:00:00',
|
||||
'last_succeeded_at' => '2026-08-21 12:00:00',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function refundInfo(int $id, int $asId, string $date, string $platform, string $shopName): array
|
||||
{
|
||||
return [
|
||||
'Id' => $id, 'as_id' => $asId, 'as_date' => $date, 'outer_as_id' => 'AS-' . $asId,
|
||||
'o_id' => 8900 + $id, 'so_id' => 'SO-' . $id, 'type' => '退款', 'node' => '完成',
|
||||
'shop_type' => '自营', 'shop_name' => $shopName, 'shop_site' => $platform,
|
||||
'refund' => 100, 'receive_refund' => 80, 'buyer_apply_refund' => 100,
|
||||
'createTime' => $date, 'status' => '完成', 'shop_status' => '完成',
|
||||
'created' => $date, 'confirm_date' => $date,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function refundItem(int $id, int $asId, string $skuId, string $outerOrderItemId): array
|
||||
{
|
||||
return [
|
||||
'Id' => $id, 'as_id' => $asId, 'outer_oi_id' => $outerOrderItemId,
|
||||
'shop_sku_id' => 'SHOP-' . $skuId, 'i_id' => 'STYLE-' . $id, 'sku_id' => $skuId,
|
||||
'qty' => 2, 'shop_amount' => 80, 'type' => '退货退款', 'r_qty' => 2,
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user