334 lines
13 KiB
PHP
334 lines
13 KiB
PHP
<?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($this->selectColumns())
|
|
->orderByDesc('r.as_date')
|
|
->orderByDesc('r.Id')
|
|
->orderByDesc('i.Id')
|
|
->forPage($page, $perPage)
|
|
->get();
|
|
|
|
$this->hydratePage($rows);
|
|
|
|
return ['data' => $rows->all(), 'total' => $total];
|
|
}
|
|
|
|
/**
|
|
* Export all rows matching the supplied filters in the same order and
|
|
* shape as the paginated page data, without loading the full result set.
|
|
*
|
|
* @param array{from:string,to:string,order_no:string,shop_name:string,sku_id:string} $filters
|
|
* @return iterable<int,object>
|
|
*/
|
|
public function exportRows(array $filters, int $chunkSize = 100): iterable
|
|
{
|
|
if (! $this->ready() || ! $this->queryReady()) {
|
|
throw new RuntimeException('business_mirror_dataset_not_ready');
|
|
}
|
|
|
|
$chunkSize = min(max($chunkSize, 1), 100);
|
|
$query = $this->baseQuery();
|
|
$this->applyFilters($query, $filters);
|
|
|
|
$page = 1;
|
|
do {
|
|
$rows = (clone $query)
|
|
->select($this->selectColumns())
|
|
->orderByDesc('r.as_date')
|
|
->orderByDesc('r.Id')
|
|
->orderByDesc('i.Id')
|
|
->forPage($page, $chunkSize)
|
|
->get();
|
|
|
|
if ($rows->isEmpty()) {
|
|
break;
|
|
}
|
|
|
|
$this->hydratePage($rows);
|
|
|
|
foreach ($rows as $row) {
|
|
yield $row;
|
|
}
|
|
|
|
$page++;
|
|
} while ($rows->count() === $chunkSize);
|
|
}
|
|
|
|
/** @return list<string> */
|
|
private function selectColumns(): array
|
|
{
|
|
return [
|
|
'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',
|
|
];
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|