415 lines
19 KiB
PHP
415 lines
19 KiB
PHP
<?php
|
|
|
|
namespace Modules\EcommerceFinance\Repositories;
|
|
|
|
use Illuminate\Database\ConnectionInterface;
|
|
use Illuminate\Database\Query\Builder;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
/** 快递应付运费对账的共享业务镜像只读访问。 */
|
|
class CourierFreightReconciliationMirrorRepository
|
|
{
|
|
/** 保留旧契约,供现有结算同步和调用方识别。 */
|
|
public const DATASET = 'jiansun.dbo.fys_express_courier_settlement';
|
|
public const DETAILS_DATASET = 'jiansun.dbo.jst_order_express_details';
|
|
public const EXTENSION_DATASET = 'jiansun.dbo.Jst_SendData_View_extension';
|
|
public const OUTBOUND_DATASET = 'jiansun.dbo.jst_salesOutboundInfo';
|
|
|
|
/** @var list<string> */
|
|
private const REQUIRED_DATASETS = [
|
|
self::DETAILS_DATASET,
|
|
self::EXTENSION_DATASET,
|
|
self::OUTBOUND_DATASET,
|
|
];
|
|
|
|
/** 仅暴露附件中有演示值、且已经核对来源的字段。 */
|
|
private const COLUMNS = [
|
|
'detail_id', 'waybill', 'product_id', 'send_time', 'transaction_order_no',
|
|
'province', 'city', 'district', 'barcode', 'express_name', 'express_name_2', 'quantity',
|
|
'product_name', 'platform', 'shop_name', 'warehouse', 'volume', 'supplier',
|
|
'length', 'width', 'height', 'freight', 'reconciliation_weight',
|
|
'reconciliation_volume', 'month', 'status',
|
|
];
|
|
|
|
private readonly ConnectionInterface $connection;
|
|
|
|
public function __construct(
|
|
private readonly BusinessMirrorDatasetRegistry $datasets,
|
|
private readonly GenericBusinessMirrorRepository $mirror,
|
|
) {
|
|
$this->connection = $mirror->connection();
|
|
}
|
|
|
|
public function available(): bool
|
|
{
|
|
try {
|
|
$schema = Schema::connection($this->connection->getName());
|
|
if (! $schema->hasTable('business_mirror_table_states')) {
|
|
return false;
|
|
}
|
|
|
|
foreach (self::REQUIRED_DATASETS as $key) {
|
|
if (! $schema->hasTable($this->datasets->get($key)->targetTable())) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
} catch (Throwable) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function ready(): bool
|
|
{
|
|
try {
|
|
foreach (self::REQUIRED_DATASETS as $key) {
|
|
if (! $this->mirror->ready($this->datasets->get($key))) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
} catch (Throwable) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** @return array<string,mixed>|null */
|
|
public function state(): ?array
|
|
{
|
|
try {
|
|
$states = [];
|
|
foreach (self::REQUIRED_DATASETS as $key) {
|
|
$state = $this->mirror->state($this->datasets->get($key));
|
|
if (is_array($state)) {
|
|
$states[$key] = $state;
|
|
}
|
|
}
|
|
if ($states === []) {
|
|
return null;
|
|
}
|
|
|
|
$first = $states[self::DETAILS_DATASET] ?? reset($states);
|
|
$initial = array_values(array_filter(array_map(
|
|
static fn (array $state): ?string => $state['initial_completed_at'] ?? null,
|
|
$states,
|
|
)));
|
|
$succeeded = array_values(array_filter(array_map(
|
|
static fn (array $state): ?string => $state['last_succeeded_at'] ?? null,
|
|
$states,
|
|
)));
|
|
$statuses = array_map(static fn (array $state): string => (string) ($state['status'] ?? 'pending'), $states);
|
|
|
|
$first['initial_completed_at'] = count($initial) === count(self::REQUIRED_DATASETS) ? min($initial) : null;
|
|
$first['last_succeeded_at'] = count($succeeded) === count(self::REQUIRED_DATASETS) ? min($succeeded) : null;
|
|
$first['status'] = in_array('failed', $statuses, true)
|
|
? 'failed'
|
|
: (in_array('running', $statuses, true) ? 'running' : (count(array_filter($statuses, static fn (string $value): bool => $value === 'succeeded')) === count(self::REQUIRED_DATASETS) ? 'succeeded' : 'pending'));
|
|
$first['datasets'] = $states;
|
|
|
|
return $first;
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** @return array{code:string,last_succeeded_at:?string,lag_minutes:?int} */
|
|
public function syncStatus(?array $state = null): array
|
|
{
|
|
$state ??= $this->state();
|
|
if (! is_array($state)) {
|
|
return ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
|
|
}
|
|
|
|
$lastSucceeded = $state['last_succeeded_at'] ?? null;
|
|
$lagMinutes = null;
|
|
if (is_string($lastSucceeded) && $lastSucceeded !== '') {
|
|
try {
|
|
$lagMinutes = (int) ceil(max(0, now()->getTimestamp() - Carbon::parse($lastSucceeded)->getTimestamp()) / 60);
|
|
} catch (Throwable) {
|
|
$lagMinutes = null;
|
|
}
|
|
}
|
|
|
|
$status = (string) ($state['status'] ?? 'pending');
|
|
if (($state['initial_completed_at'] ?? null) === null) {
|
|
$code = $status === 'failed' ? 'initial_failed' : ($status === 'running' ? 'initializing' : 'pending');
|
|
} elseif ($status === 'failed') {
|
|
$code = 'failed';
|
|
} elseif ($status === 'running') {
|
|
$code = 'syncing';
|
|
} elseif ($lagMinutes === null || $lagMinutes > max((int) config('upstream_mirrors.courier_information.stale_after_minutes', 60), 1)) {
|
|
$code = 'stale';
|
|
} else {
|
|
$code = 'fresh';
|
|
}
|
|
|
|
return ['code' => $code, 'last_succeeded_at' => $lastSucceeded, 'lag_minutes' => $lagMinutes];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,string> $filters
|
|
* @return array{data:list<array<string,mixed>>,total:?int,has_more:bool,summary:?array,state:array<string,mixed>|null,sync:array<string,mixed>}
|
|
*/
|
|
public function paginate(array $filters, int $page, int $perPage): array
|
|
{
|
|
if (! $this->ready()) {
|
|
throw new RuntimeException('courier_freight_reconciliation_mirror_not_ready');
|
|
}
|
|
|
|
$page = max($page, 1);
|
|
$perPage = min(max($perPage, 1), 100);
|
|
$summary = Cache::get($this->summaryCacheKey($filters));
|
|
// Select page identifiers before loading the wide optional details. A
|
|
// contains-waybill filter can then scan the compact covering index.
|
|
$candidates = $this->filteredQuery($filters, trim((string) ($filters['express_name'] ?? '')) !== '')
|
|
->select('d.id')
|
|
->orderByDesc('d.id')
|
|
->offset(($page - 1) * $perPage)->limit($perPage + 1);
|
|
$rows = $this->detailQuery($candidates)->select($this->selectColumns())
|
|
->orderByDesc('d.id')
|
|
->get()
|
|
->map(fn (object $row): array => $this->mapRow($row))
|
|
->all();
|
|
$state = $this->state();
|
|
|
|
return [
|
|
'data' => array_slice($rows, 0, $perPage),
|
|
'total' => $summary['count'] ?? null,
|
|
'has_more' => count($rows) > $perPage,
|
|
'summary' => $summary,
|
|
'state' => $state,
|
|
'sync' => $this->syncStatus($state),
|
|
];
|
|
}
|
|
|
|
/** Send a small pending response before starting the bounded calculation. */
|
|
public function requestSummary(array $filters): ?array
|
|
{
|
|
$key = $this->summaryCacheKey($filters);
|
|
if (is_array($cached = Cache::get($key))) return $cached;
|
|
if (Cache::has($key . ':failed')) throw new RuntimeException('courier_summary_recently_failed');
|
|
app()->terminating(function () use ($filters, $key): void {
|
|
try {
|
|
$this->summarize($filters);
|
|
} catch (Throwable $exception) {
|
|
Cache::put($key . ':failed', true, 60);
|
|
report($exception);
|
|
}
|
|
});
|
|
return null;
|
|
}
|
|
|
|
/** The separate summary request preserves full-filter totals without delaying rows. */
|
|
public function summarize(array $filters): ?array
|
|
{
|
|
if (! $this->ready()) throw new RuntimeException('courier_freight_reconciliation_mirror_not_ready');
|
|
$key = $this->summaryCacheKey($filters);
|
|
if (is_array($cached = Cache::get($key))) return $cached;
|
|
// One expensive aggregate at a time, including requests for different filters.
|
|
$lock = Cache::lock('courier-freight-summary:calculation', 360);
|
|
if (! $lock->get()) return null;
|
|
$previousSettings = [];
|
|
try {
|
|
if (is_array($cached = Cache::get($key))) return $cached;
|
|
if ($this->connection->getDriverName() === 'mysql') {
|
|
$settings = ['max_statement_time' => 300];
|
|
if (array_filter($filters, static fn ($value): bool => trim((string) $value) !== '') === []) {
|
|
$settings += [
|
|
'optimizer_switch' => 'derived_merge=off,derived_with_keys=off',
|
|
'join_cache_level' => 8,
|
|
'join_buffer_size' => 16777216,
|
|
'join_buffer_space_limit' => 33554432,
|
|
];
|
|
}
|
|
foreach ($settings as $name => $value) {
|
|
$previousSettings[$name] = $this->connection->selectOne('SELECT @@SESSION.' . $name . ' AS value')->value;
|
|
$this->setSummarySessionSetting($name, $value);
|
|
}
|
|
}
|
|
$asOf = now()->toDateTimeString();
|
|
$row = $this->summaryQuery($filters)->selectRaw(
|
|
'COUNT(d.id) AS aggregate_count, COALESCE(SUM(o.freight), 0) AS freight',
|
|
)->first();
|
|
$summary = ['count' => (int) $row->aggregate_count, 'freight' => (float) $row->freight, 'computed_at' => $asOf];
|
|
Cache::put($key, $summary, 900);
|
|
return $summary;
|
|
} finally {
|
|
try {
|
|
foreach (array_reverse($previousSettings, true) as $name => $value) {
|
|
$this->setSummarySessionSetting($name, $value);
|
|
}
|
|
} finally {
|
|
$lock->release();
|
|
}
|
|
}
|
|
}
|
|
|
|
private function setSummarySessionSetting(string $name, mixed $value): void
|
|
{
|
|
if ($name === 'optimizer_switch') {
|
|
$this->connection->statement('SET SESSION optimizer_switch=?', [$value]);
|
|
return;
|
|
}
|
|
// MariaDB rejects quoted numeric values for these session variables.
|
|
// Names come only from the fixed settings map above.
|
|
$this->connection->statement('SET SESSION ' . $name . '=' . (float) $value);
|
|
}
|
|
|
|
private function summaryCacheKey(array $filters): string
|
|
{
|
|
$filters = array_filter(array_map(static fn ($value): string => trim((string) $value), $filters), static fn (string $value): bool => $value !== '');
|
|
ksort($filters);
|
|
return 'courier-freight-summary:v3:' . hash('sha256', json_encode($filters));
|
|
}
|
|
|
|
/** Materialize latest associations once; use compact collation-aware hash joins for the full scan. */
|
|
private function summaryQuery(array $filters = []): Builder
|
|
{
|
|
$activeFilters = array_filter($filters, static fn ($value): bool => trim((string) $value) !== '');
|
|
if ($activeFilters !== []) {
|
|
$ids = $this->filteredQuery($filters, ($filters['express_name'] ?? '') !== '')->select('d.id')->distinct();
|
|
return $this->detailQuery($ids);
|
|
}
|
|
$columns = ['so_id', 'io_id'];
|
|
$lookup = fn (string $column): string => $this->connection->getDriverName() === 'mysql'
|
|
? 'CRC32(WEIGHT_STRING(RTRIM(' . $column . ')))'
|
|
: 'SUBSTR(' . $column . ', 1, 64)';
|
|
$extension = $this->latestSummaryProjection(self::EXTENSION_DATASET, 'so_id', $columns)
|
|
->selectRaw($lookup('candidate.so_id') . ' AS lookup_key');
|
|
$outbound = $this->latestSummaryProjection(self::OUTBOUND_DATASET, 'io_id', ['io_id', 'freight']);
|
|
$query = $this->connection->table($this->datasets->get(self::DETAILS_DATASET)->targetTable() . ' as d')
|
|
->leftJoinSub($extension, 'x', static function ($join) use ($lookup): void {
|
|
$join->whereRaw('x.lookup_key = ' . $lookup('d.so_id'))
|
|
// Keep the full comparison: a hash collision must never
|
|
// combine different orders or multiply freight amounts.
|
|
->whereRaw("COALESCE(x.so_id, '') = COALESCE(d.so_id, '')");
|
|
})
|
|
->leftJoinSub($outbound, 'o', 'o.io_id', '=', 'x.io_id');
|
|
return $this->filteredQuery([], true, $query);
|
|
}
|
|
|
|
private function latestSummaryProjection(string $dataset, string $relation, array $columns): Builder
|
|
{
|
|
$table = $this->datasets->get($dataset)->targetTable();
|
|
$ids = $this->connection->table($table)->select($relation)->selectRaw('MAX(Id) AS latest_id')->groupBy($relation)->orderByRaw('NULL');
|
|
return $this->connection->query()->fromSub($ids, 'latest_ids')
|
|
->join($table . ' as candidate', static function ($join) use ($relation): void {
|
|
$join->on('latest_ids.' . $relation, '=', 'candidate.' . $relation)->on('latest_ids.latest_id', '=', 'candidate.Id');
|
|
})->select(array_map(static fn (string $column): string => 'candidate.' . $column, $columns));
|
|
}
|
|
|
|
private function filteredQuery(array $filters, bool $includeRelations = true, ?Builder $base = null): Builder
|
|
{
|
|
$query = ($base ?? ($includeRelations ? $this->detailQuery() : $this->connection->table($this->datasets->get(self::DETAILS_DATASET)->targetTable() . ' as d')))
|
|
->whereNotNull('d.logistic_id')->where('d.logistic_id', '!=', '');
|
|
$this->containsFilter($query, 'd.logistic_id', $filters['express_no'] ?? '');
|
|
|
|
if (($filters['express_name'] ?? '') !== '') {
|
|
$needle = $this->contains((string) $filters['express_name']);
|
|
$query->where(function (Builder $name) use ($needle): void {
|
|
$name->whereRaw("d.logistic_company_name LIKE ? ESCAPE '!'", [$needle])
|
|
->orWhereRaw("x.expressName LIKE ? ESCAPE '!'", [$needle]);
|
|
});
|
|
}
|
|
if (($filters['month'] ?? '') !== '' && preg_match('/^\d{4}-\d{2}$/', (string) $filters['month']) === 1) {
|
|
$from = (string) $filters['month'] . '-01';
|
|
$query->where('d.send_time', '>=', $from . ' 00:00:00')
|
|
->where('d.send_time', '<', date('Y-m-d 00:00:00', strtotime($from . ' +1 month')));
|
|
}
|
|
if (($filters['status'] ?? '') !== '') {
|
|
$query->where('d.logistic_status_name', '=', $filters['status']);
|
|
}
|
|
if (($filters['modified_from'] ?? '') !== '') {
|
|
$query->where('d.updatedAt', '>=', $filters['modified_from'] . ' 00:00:00');
|
|
}
|
|
if (($filters['modified_to'] ?? '') !== '') {
|
|
$query->where('d.updatedAt', '<', date('Y-m-d 00:00:00', strtotime($filters['modified_to'] . ' +1 day')));
|
|
}
|
|
|
|
return $query;
|
|
}
|
|
|
|
private function detailQuery(?Builder $pageIds = null): Builder
|
|
{
|
|
$details = $this->datasets->get(self::DETAILS_DATASET)->targetTable();
|
|
$extension = $this->datasets->get(self::EXTENSION_DATASET)->targetTable();
|
|
$outbound = $this->datasets->get(self::OUTBOUND_DATASET)->targetTable();
|
|
|
|
$query = $pageIds === null ? $this->connection->table($details . ' as d')
|
|
: $this->connection->query()->fromSub($pageIds, 'page_ids')->join($details . ' as d', 'd.id', '=', 'page_ids.id');
|
|
return $query
|
|
->leftJoinSub($this->latestBy($extension, 'so_id', 'Id'), 'x', 'x.so_id', '=', 'd.so_id')
|
|
->leftJoinSub($this->latestBy($outbound, 'io_id', 'Id'), 'o', 'o.io_id', '=', 'x.io_id');
|
|
}
|
|
|
|
/** 固定取关联键下 Id 最大的一行,避免一对多重复页面行和运费汇总。 */
|
|
private function latestBy(string $table, string $relationColumn, string $idColumn): Builder
|
|
{
|
|
return $this->connection->table($table . ' as candidate')
|
|
->whereNotExists(function (Builder $query) use ($table, $relationColumn, $idColumn): void {
|
|
$query->selectRaw('1')
|
|
->from($table . ' as newer')
|
|
->whereColumn('newer.' . $relationColumn, '=', 'candidate.' . $relationColumn)
|
|
->whereColumn('newer.' . $idColumn, '>', 'candidate.' . $idColumn);
|
|
});
|
|
}
|
|
|
|
/** @return list<string> */
|
|
private function selectColumns(): array
|
|
{
|
|
return [
|
|
'd.id as detail_id', 'd.logistic_id as waybill', 'x.shop_i_id as product_id',
|
|
'd.send_time', $this->connection->raw('COALESCE(x.so_id, d.so_id) as transaction_order_no'), 'x.receiver_state as province',
|
|
'x.receiver_city as city', 'x.receiver_district as district',
|
|
'x.sku_ids as barcode',
|
|
'd.logistic_company_name as express_name', 'x.expressName as express_name_2',
|
|
'x.qty as quantity', 'x.sku_name as product_name', 'd.platform_name as platform',
|
|
$this->connection->raw('COALESCE(x.shop_name, d.shop_name) as shop_name'),
|
|
$this->connection->raw('COALESCE(x.wareHoursName, d.warehouse_name) as warehouse'), 'x.volume as volume',
|
|
'x.supplier_name as supplier', 'x.l as length', 'x.w as width', 'x.h as height',
|
|
'o.freight as freight', 'd.f_weight as reconciliation_weight',
|
|
'x.volume as reconciliation_volume', 'd.logistic_status_name as status',
|
|
];
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
private function mapRow(object $row): array
|
|
{
|
|
$mapped = [];
|
|
foreach (self::COLUMNS as $column) {
|
|
$mapped[$column] = $row->{$column} ?? null;
|
|
}
|
|
|
|
$sendTime = $mapped['send_time'];
|
|
$mapped['month'] = is_string($sendTime) ? substr($sendTime, 0, 7) : null;
|
|
foreach (['length', 'width', 'height', 'volume', 'freight', 'reconciliation_weight', 'reconciliation_volume'] as $number) {
|
|
$mapped[$number] = $mapped[$number] === null || $mapped[$number] === '' ? null : (float) $mapped[$number];
|
|
}
|
|
if ($mapped['quantity'] !== null && $mapped['quantity'] !== '') {
|
|
$mapped['quantity'] = (int) $mapped['quantity'];
|
|
}
|
|
|
|
return $mapped;
|
|
}
|
|
|
|
private function containsFilter(Builder $query, string $column, mixed $value): void
|
|
{
|
|
$value = trim((string) $value);
|
|
if ($value !== '') {
|
|
$query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains($value)]);
|
|
}
|
|
}
|
|
|
|
private function contains(string $value): string
|
|
{
|
|
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%';
|
|
}
|
|
}
|