CaiWuXiTong/modules/EcommerceFinance/Repositories/AfterSalesCostRowRepository.php
renshen d5cc75082c 优化月度导入数据首页与批量编码查询 (#582)
增加月份订单索引并优化批量编码查询
2026-10-26 11:30:01 +08:00

608 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 订单售后费用明细(聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」导入)。
*
* 聚水潭报表已经按商品编码(组合装拆成组件)给出行和成本,页面直接读这张导入表,
* 不再从订单明细里自己拆组合装。
*/
class AfterSalesCostRowRepository
{
private const TABLE = 'ops_after_sales_cost_rows';
private const INTERNAL_ORDER_MAX_PREFIX_LENGTH = 11;
private ?bool $available = null;
public function available(): bool
{
if ($this->available === null) {
try {
$this->available = Schema::hasTable(self::TABLE)
&& DB::table(self::TABLE)->exists();
} catch (\Throwable) {
$this->available = false;
}
}
return $this->available;
}
/**
* 导入数据是否覆盖本次筛选(有至少一行命中即视为覆盖)。
*
* @param array<string,mixed> $filters
*/
public function covers(array $filters): bool
{
if (! $this->available()) {
return false;
}
// 指定单号时,导入表里命中该单就完整(同单的所有明细行都在导入数据里)。
if (trim((string) ($filters['order_no'] ?? '')) !== '') {
return $this->orderNumberExists($filters);
}
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
// 不限日期时无法保证覆盖范围,回退镜像,避免把导入数据当成全量。
if ($from === '' || $to === '') {
return false;
}
$period = self::fullMonthPeriod($from, $to);
if ($period !== null && $this->periodHasAllSelectedShops($period, $filters)) {
return true;
}
// 兼容旧数据:此前导入没有 source_period,只能按订单时间范围判断覆盖。
$range = $this->legacyFilteredRange($filters)
->selectRaw('MIN(order_time) as min_time, MAX(order_time) as max_time')
->first();
if (! $range || $range->min_time === null) {
return false;
}
// 只有导入范围完整覆盖所选日期时才使用导入数据,避免跨月时数据不全。
return substr((string) $range->min_time, 0, 10) <= $from
&& substr((string) $range->max_time, 0, 10) >= $to;
}
/**
* @param array<string,mixed> $filters
* @return array{rows:list<array<string,mixed>>,has_more:bool}
*/
public function paginate(array $filters, int $page, int $limit): array
{
$page = max($page, 1);
$limit = min(max($limit, 1), 100);
if (trim((string) ($filters['order_no'] ?? '')) !== '') {
return $this->paginateByOrderNumbers($filters, $page, $limit);
}
if (count(self::batchValues((string) ($filters['sku_id'] ?? ''))) > 1) {
return $this->paginateBySkuValues($filters, $page, $limit);
}
$records = $this->filtered($filters)
->orderByDesc('order_time')
->orderByDesc('id')
->forPage($page, $limit + 1)
->get();
$rows = [];
foreach ($records as $record) {
$payload = json_decode((string) $record->payload, true);
$rows[] = is_array($payload) ? $payload : [];
}
return [
'rows' => array_slice($rows, 0, $limit),
'has_more' => count($rows) > $limit,
];
}
/** @return list<string> */
public function shopNames(): array
{
if (! $this->available()) {
return [];
}
return Cache::remember(
'order-data.after-sales.imported-shop-options',
now()->addHours(24),
static fn (): array => DB::table(self::TABLE)
->whereNotNull('source_period')
->whereNotNull('shop_name')
->where('shop_name', '<>', '')
->distinct()
->orderBy('shop_name')
->pluck('shop_name')
->map(static fn (mixed $name): string => trim((string) $name))
->filter()
->values()
->all(),
);
}
/**
* 商品成本补拆:按线上订单号批量读取导入表中已经拆好的组件行。
*
* 只返回拆分所需的商品、平台规格、数量和金额;单价仍由商品成本的
* 月加权平均价 / 线上代发价 / 普通商品资料成本价链路计算。
*
* @param list<string> $onlineOrderNos
* @return list<array{
* shop_name:string,
* online_order_no:string,
* internal_order_no:string,
* product_code:string,
* shop_product_code:string,
* shop_style_code:string,
* product_quantity:float,
* product_amount:float
* }>
*/
public function componentRowsForOrders(array $onlineOrderNos): array
{
$onlineOrderNos = array_values(array_unique(array_filter(array_map(
static fn (mixed $value): string => trim((string) $value),
$onlineOrderNos,
), static fn (string $value): bool => $value !== '')));
sort($onlineOrderNos, SORT_STRING);
if ($onlineOrderNos === [] || ! $this->available()) {
return [];
}
$rows = [];
foreach (array_chunk($onlineOrderNos, 500) as $chunk) {
foreach (DB::table(self::TABLE)
->whereIn('online_order_no', $chunk)
->orderBy('id')
->get([
'shop_name',
'online_order_no',
'internal_order_no',
'product_code',
'product_quantity',
'product_amount',
'payload',
]) as $record) {
$payload = json_decode((string) ($record->payload ?? ''), true);
$payload = is_array($payload) ? $payload : [];
$productCode = mb_strtoupper(trim((string) (
$record->product_code ?? ($payload['product_code'] ?? '')
)));
if ($productCode === '') {
continue;
}
$rows[] = [
'shop_name' => trim((string) ($record->shop_name ?? '')),
'online_order_no' => trim((string) ($record->online_order_no ?? '')),
'internal_order_no' => trim((string) ($record->internal_order_no ?? '')),
'product_code' => $productCode,
'shop_product_code' => trim((string) ($payload['shop_product_code'] ?? '')),
'shop_style_code' => trim((string) ($payload['shop_style_code'] ?? '')),
'product_quantity' => (float) ($record->product_quantity ?? 0),
'product_amount' => (float) ($record->product_amount ?? 0),
];
}
}
return $rows;
}
/**
* 导入一行(命令内部使用,批量写入)。
*
* @param array<string,mixed> $payload
*/
public static function toRecord(
array $payload,
?string $importedAt = null,
?string $sourcePeriod = null,
): array
{
// 以整行内容做键:同一单同一商品可能出现多行,只有完全相同的一行才合并。
ksort($payload);
$rowKey = sha1((string) json_encode(
['source_period' => $sourcePeriod, 'payload' => $payload],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
));
$now = $importedAt ?? now()->toDateTimeString();
return [
'row_key' => $rowKey,
'shop_name' => mb_substr((string) ($payload['shop_name'] ?? ''), 0, 191),
'shop_code' => mb_substr((string) ($payload['shop_code'] ?? ''), 0, 64),
'online_order_no' => mb_substr((string) ($payload['online_order_no'] ?? ''), 0, 64),
'internal_order_no' => mb_substr((string) ($payload['internal_order_no'] ?? ''), 0, 64),
'product_code' => mb_substr((string) ($payload['product_code'] ?? ''), 0, 191),
'style_code' => mb_substr((string) ($payload['style_code'] ?? ''), 0, 191),
'shop_product_code' => mb_substr((string) ($payload['shop_product_code'] ?? ''), 0, 191),
'shop_style_code' => mb_substr((string) ($payload['shop_style_code'] ?? ''), 0, 191),
'order_time' => self::dateTime($payload['order_time'] ?? null),
'source_period' => $sourcePeriod,
'product_quantity' => (float) ($payload['product_quantity'] ?? 0),
'product_amount' => (float) ($payload['product_amount'] ?? 0),
'product_cost' => (float) ($payload['product_cost'] ?? 0),
'cost_source' => mb_substr((string) ($payload['cost_source'] ?? ''), 0, 32),
'combine_type' => mb_substr((string) ($payload['combine_type'] ?? ''), 0, 32),
'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'imported_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
}
/** @param array<string,mixed> $filters */
private function filtered(array $filters): Builder
{
$query = DB::table(self::TABLE);
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
$sku = trim((string) ($filters['sku_id'] ?? ''));
$period = self::fullMonthPeriod($from, $to);
if ($period !== null) {
$query->where(function (Builder $range) use ($period, $from, $to): void {
$range->where('source_period', $period)
->orWhere(function (Builder $legacy) use ($from, $to): void {
$legacy->whereNull('source_period')
->where('order_time', '>=', $from . ' 00:00:00')
->where('order_time', '<=', $to . ' 23:59:59');
});
});
} elseif ($from !== '' && $to !== '') {
$query->where(function (Builder $range) use ($from, $to): void {
$range->whereBetween('source_period', [substr($from, 0, 7), substr($to, 0, 7)])
->orWhere(function (Builder $legacy) use ($from, $to): void {
$legacy->whereNull('source_period')
->where('order_time', '>=', $from . ' 00:00:00')
->where('order_time', '<=', $to . ' 23:59:59');
});
});
} elseif ($from !== '') {
$query->where('order_time', '>=', $from . ' 00:00:00');
} elseif ($to !== '') {
$query->where('order_time', '<=', $to . ' 23:59:59');
}
$this->applyShopFilters($query, $filters);
$skuValues = self::batchValues($sku);
if ($skuValues !== []) {
$query->where(function (Builder $codes) use ($skuValues): void {
foreach ($skuValues as $value) {
$contains = self::contains($value);
$codes->orWhere(function (Builder $group) use ($contains): void {
$group->whereRaw("product_code LIKE ? ESCAPE '!'", [$contains])
->orWhereRaw("style_code LIKE ? ESCAPE '!'", [$contains])
->orWhereRaw("shop_product_code LIKE ? ESCAPE '!'", [$contains])
->orWhereRaw("shop_style_code LIKE ? ESCAPE '!'", [$contains]);
});
}
});
}
return $query;
}
/**
* 单号查询拆成线上/内部两个独立索引查询,避免 OR 让数据库扫描整个月份。
*
* @param array<string,mixed> $filters
* @return array{rows:list<array<string,mixed>>,has_more:bool}
*/
private function paginateByOrderNumbers(array $filters, int $page, int $limit): array
{
$values = self::batchValues((string) ($filters['order_no'] ?? ''));
if ($values === []) {
return ['rows' => [], 'has_more' => false];
}
$baseFilters = array_replace($filters, ['order_no' => '']);
$cap = max($limit * 20, 1000);
$ids = [];
foreach (['online_order_no', 'internal_order_no'] as $field) {
$fieldValues = self::orderValuesForField($field, $values);
if ($fieldValues === []) {
continue;
}
$query = $this->orderNumberBranch($baseFilters, $field, $fieldValues)
->select('id')
->limit($cap);
foreach ($query->pluck('id') as $id) {
$ids[(int) $id] = true;
}
}
if ($ids === []) {
return ['rows' => [], 'has_more' => false];
}
$records = DB::table(self::TABLE)
->whereIn('id', array_keys($ids))
->orderByDesc('order_time')
->orderByDesc('id')
->forPage($page, $limit + 1)
->get();
$rows = [];
foreach ($records as $record) {
$payload = json_decode((string) $record->payload, true);
$rows[] = is_array($payload) ? $payload : [];
}
return [
'rows' => array_slice($rows, 0, $limit),
'has_more' => count($rows) > $limit,
];
}
/**
* 批量商品编码按四列稳定索引取候选行,避免四列 contains 扫描整个月份。
*
* @param array<string,mixed> $filters
* @return array{rows:list<array<string,mixed>>,has_more:bool}
*/
private function paginateBySkuValues(array $filters, int $page, int $limit): array
{
$values = self::batchValues((string) ($filters['sku_id'] ?? ''));
if (count($values) < 2) {
return ['rows' => [], 'has_more' => false];
}
$baseFilters = array_replace($filters, ['sku_id' => '']);
$needed = $page * $limit + 1;
$ids = [];
foreach (['product_code', 'style_code', 'shop_product_code', 'shop_style_code'] as $field) {
$query = $this->filtered($baseFilters)
->whereIn($field, $values)
->orderByDesc('order_time')
->orderByDesc('id')
->limit($needed);
foreach ($query->pluck('id') as $id) {
$ids[(int) $id] = true;
}
}
if ($ids === []) {
return ['rows' => [], 'has_more' => false];
}
$records = DB::table(self::TABLE)
->whereIn('id', array_keys($ids))
->orderByDesc('order_time')
->orderByDesc('id')
->forPage($page, $limit + 1)
->get();
$rows = [];
foreach ($records as $record) {
$payload = json_decode((string) $record->payload, true);
$rows[] = is_array($payload) ? $payload : [];
}
return [
'rows' => array_slice($rows, 0, $limit),
'has_more' => count($rows) > $limit,
];
}
/** @param array<string,mixed> $filters */
private function orderNumberExists(array $filters): bool
{
$values = self::batchValues((string) ($filters['order_no'] ?? ''));
if ($values === []) {
return false;
}
$baseFilters = array_replace($filters, ['order_no' => '']);
foreach (['online_order_no', 'internal_order_no'] as $field) {
$fieldValues = self::orderValuesForField($field, $values);
if ($fieldValues !== []
&& $this->orderNumberBranch($baseFilters, $field, $fieldValues)->limit(1)->exists()) {
return true;
}
}
return false;
}
/**
* @param array<string,mixed> $filters
* @param list<string> $values
*/
private function orderNumberBranch(array $filters, string $field, array $values): Builder
{
$query = $this->filtered($filters);
if (count($values) === 1) {
return $query->where($field, 'like', self::likePrefix($values[0]));
}
return $query->whereIn($field, $values);
}
/** @param array<string,mixed> $filters */
private function legacyFilteredRange(array $filters): Builder
{
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
$query = DB::table(self::TABLE)->whereNull('source_period');
if ($from !== '') {
$query->where('order_time', '>=', $from . ' 00:00:00');
}
if ($to !== '') {
$query->where('order_time', '<=', $to . ' 23:59:59');
}
$this->applyShopFilters($query, $filters);
return $query;
}
/** @param array<string,mixed> $filters */
private function periodHasAllSelectedShops(string $period, array $filters): bool
{
$query = DB::table(self::TABLE)->where('source_period', $period);
$this->applyShopFilters($query, $filters);
$shops = self::selectedShopNames($filters);
if ($shops === []) {
return $query->exists();
}
$covered = $query->distinct()->pluck('shop_name')
->map(static fn (mixed $name): string => trim((string) $name))
->all();
$exact = ! empty($filters['shop_names_exact']);
foreach ($shops as $shop) {
$found = false;
foreach ($covered as $candidate) {
if ($exact ? $candidate === $shop : mb_stripos($candidate, $shop) !== false) {
$found = true;
break;
}
}
if (! $found) {
return false;
}
}
return true;
}
/** @param array<string,mixed> $filters */
private function applyShopFilters(Builder $query, array $filters): void
{
$shops = self::selectedShopNames($filters);
if ($shops === []) {
return;
}
if (! empty($filters['shop_names_exact'])) {
$query->whereIn('shop_name', $shops);
return;
}
$query->where(function (Builder $shop) use ($shops): void {
foreach ($shops as $name) {
$shop->orWhereRaw("shop_name LIKE ? ESCAPE '!'", [self::contains($name)]);
}
});
}
/** @return list<string> */
private static function selectedShopNames(array $filters): array
{
$values = (array) ($filters['shop_names'] ?? []);
if ($values === [] && trim((string) ($filters['shop_name'] ?? '')) !== '') {
$values = [$filters['shop_name']];
}
$names = [];
foreach ($values as $value) {
$name = trim((string) $value);
if ($name !== '') {
$names[$name] = true;
}
if (count($names) >= 50) {
break;
}
}
return array_keys($names);
}
private static function fullMonthPeriod(string $from, string $to): ?string
{
if (preg_match('/^(\d{4}-\d{2})-01$/', $from, $matches) !== 1) {
return null;
}
$period = $matches[1];
return $to === date('Y-m-t', strtotime($period . '-01')) ? $period : null;
}
private static function contains(string $value): string
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
}
private static function likePrefix(string $value): string
{
return str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
}
/** @param list<string> $values @return list<string> */
private static function orderValuesForField(string $field, array $values): array
{
if ($field !== 'internal_order_no') {
return $values;
}
return array_values(array_filter(
$values,
static fn (string $value): bool => mb_strlen($value) <= self::INTERNAL_ORDER_MAX_PREFIX_LENGTH,
));
}
/** @return list<string> */
private static function batchValues(string $value): array
{
$parts = preg_split('/[\s,,;;]+/u', trim($value)) ?: [];
$values = [];
foreach ($parts as $part) {
$part = trim($part);
if ($part !== '') {
$values[$part] = true;
}
if (count($values) >= 100) {
break;
}
}
return array_keys($values);
}
private static function dateTime(mixed $value): ?string
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
}