CaiWuXiTong/modules/EcommerceFinance/Repositories/GoodsProfitAfsRepository.php
renshen 537aa8d21e 提前跳过未覆盖月份的宽表查询 (#580)
6 月查询按日期边界直接跳过商品利润宽表
2026-10-26 11:25:11 +08:00

685 lines
25 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\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\GoodsProfitAfsLookupSchema;
/**
* 订单售后费用明细:聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」原表。
*
* 原表 rpt.jst_goods_profit_kpi_items_afs 的列名是 undefined1..undefinedN,
* 中文表头放在 rpt.jst_goods_profit_kpi_items_afs_schemas(column → name)。
* 两张表都镜像到共享业务库后,页面按 schemas 映射读取字段。
*/
class GoodsProfitAfsRepository
{
public const MAIN_DATASET = 'operations_replica.rpt.jst_goods_profit_kpi_items_afs';
public const SCHEMA_DATASET = 'operations_replica.rpt.jst_goods_profit_kpi_items_afs_schemas';
private const MAIN_TABLE = 'ops_jst_goods_profit_kpi_items_afs';
private const SCHEMA_TABLE = 'ops_jst_goods_profit_kpi_items_afs_schemas';
/** @var array<string,string>|null */
private ?array $fieldColumns = null;
private ?bool $hasDateLookup = null;
public function ready(): bool
{
try {
return Schema::connection($this->connectionName())->hasTable(self::MAIN_TABLE)
&& Schema::connection($this->connectionName())->hasTable(self::SCHEMA_TABLE)
&& $this->initialSyncCompleted()
&& $this->connection()->table(self::MAIN_TABLE)->exists();
} catch (\Throwable) {
return false;
}
}
/** 首次全量同步完成前不对外使用,避免页面读到不完整的数据。 */
private function initialSyncCompleted(): bool
{
$state = $this->connection()->table('business_mirror_table_states')
->where('dataset_key', self::MAIN_DATASET)
->first(['initial_completed_at']);
return $state !== null && $state->initial_completed_at !== null;
}
/**
* 页面字段键 => 上游列名(undefinedN)。
*
* @return array<string,string>
*/
public function fieldColumns(): array
{
if ($this->fieldColumns !== null) {
return $this->fieldColumns;
}
$headers = (array) config('order_after_sales_cost.headers', []);
$map = [];
$existing = array_map(
'strtolower',
Schema::connection($this->connectionName())->getColumnListing(self::MAIN_TABLE),
);
foreach ($this->connection()->table(self::SCHEMA_TABLE)->orderBy('id')->get(['column', 'name']) as $schema) {
$label = self::normalizeLabel((string) $schema->name);
$field = $headers[$label] ?? null;
$column = (string) $schema->column;
// 同一科目出现多次(如 800105佣金 / 800105平台佣金)时保留第一次映射。
// schemas 里有 170 条,但原表只有 160 列,提前过滤掉不存在的列。
if ($field !== null && ! isset($map[$field]) && in_array(strtolower($column), $existing, true)) {
$map[$field] = $column;
}
}
return $this->fieldColumns = $map;
}
/**
* 按「店铺 + 店铺商品编码 + 店铺款式编码」批量读取售后镜像中的内部商品编码。
*
* 仅作为商品成本历史拆分映射失败后的补充证据,不直接修改售后页面数据。
*
* @param list<array{shop_name:string,shop_sku_id:string,shop_i_id:string}> $candidates
* @return list<array{shop_name:string,shop_sku_id:string,shop_i_id:string,product_code:string}>
*/
public function platformSkuMappings(array $candidates): array
{
$keys = [];
foreach ($candidates as $candidate) {
$shopName = trim((string) ($candidate['shop_name'] ?? ''));
$shopSkuId = trim((string) ($candidate['shop_sku_id'] ?? ''));
$shopItemId = trim((string) ($candidate['shop_i_id'] ?? ''));
if ($shopName === '' || $shopSkuId === '' || $shopItemId === '') {
continue;
}
$keys[$shopName . "\0" . $shopSkuId . "\0" . $shopItemId] = [
'shop_name' => $shopName,
'shop_sku_id' => $shopSkuId,
'shop_i_id' => $shopItemId,
];
}
if ($keys === []) {
return [];
}
$columns = $this->fieldColumns();
foreach (['shop_name', 'shop_product_code', 'shop_style_code', 'product_code'] as $field) {
if (! isset($columns[$field])
|| preg_match('/^[A-Za-z0-9_]+$/', (string) $columns[$field]) !== 1) {
return [];
}
}
$connection = $this->connection();
$table = '`' . self::MAIN_TABLE . '`';
$shopName = '`' . $columns['shop_name'] . '`';
$shopProductCode = '`' . $columns['shop_product_code'] . '`';
$shopStyleCode = '`' . $columns['shop_style_code'] . '`';
$productCode = '`' . $columns['product_code'] . '`';
$connection->statement('DROP TEMPORARY TABLE IF EXISTS tmp_cost_afs_sku_keys');
$connection->statement(
'CREATE TEMPORARY TABLE tmp_cost_afs_sku_keys ('
. 'shop_name varchar(191) NOT NULL, '
. 'shop_sku_id varchar(128) NOT NULL, '
. 'shop_i_id varchar(128) NOT NULL, '
. 'PRIMARY KEY (shop_name, shop_sku_id, shop_i_id)'
. ') ENGINE=InnoDB',
);
$rows = [];
try {
foreach (array_chunk(array_values($keys), 500) as $chunk) {
$connection->table('tmp_cost_afs_sku_keys')->insertOrIgnore($chunk);
}
$records = $connection->select(
"SELECT DISTINCT
m.{$shopName} AS shop_name,
m.{$shopProductCode} AS shop_sku_id,
m.{$shopStyleCode} AS shop_i_id,
m.{$productCode} AS product_code
FROM {$table} m
STRAIGHT_JOIN tmp_cost_afs_sku_keys k
ON k.shop_name = m.{$shopName}
AND k.shop_sku_id = m.{$shopProductCode}
AND k.shop_i_id = m.{$shopStyleCode}
WHERE COALESCE(TRIM(m.{$productCode}), '') <> ''",
);
foreach ($records as $record) {
$rows[] = [
'shop_name' => trim((string) $record->shop_name),
'shop_sku_id' => trim((string) $record->shop_sku_id),
'shop_i_id' => trim((string) $record->shop_i_id),
'product_code' => mb_strtoupper(trim((string) $record->product_code)),
];
}
} finally {
$connection->statement('DROP TEMPORARY TABLE IF EXISTS tmp_cost_afs_sku_keys');
}
return $rows;
}
/**
* 商品成本补码:读取指定订单中数量、金额均为正的商品利润组件。
*
* @param list<string> $onlineOrderNos
* @return list<array{
* online_order_no:string,
* shop_name:string,
* shop_sku_id:string,
* shop_i_id:string,
* product_code:string,
* product_quantity:float,
* product_amount:float
* }>
*/
public function positiveComponentRowsForOrders(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->ready()) {
return [];
}
$columns = $this->fieldColumns();
foreach ([
'online_order_no', 'shop_name', 'shop_product_code', 'shop_style_code',
'product_code', 'product_quantity', 'product_amount',
] as $field) {
if (! isset($columns[$field])) {
return [];
}
}
$select = array_values(array_unique(array_values(array_intersect_key(
$columns,
array_flip([
'online_order_no', 'shop_name', 'shop_product_code', 'shop_style_code',
'product_code', 'product_quantity', 'product_amount',
]),
))));
$rows = [];
foreach (array_chunk($onlineOrderNos, 500) as $chunk) {
foreach ($this->connection()->table(self::MAIN_TABLE)
->whereIn($columns['online_order_no'], $chunk)
->orderBy('id')
->get($select) as $record) {
$productQuantity = (float) ($record->{$columns['product_quantity']} ?? 0);
$productAmount = (float) ($record->{$columns['product_amount']} ?? 0);
$productCode = mb_strtoupper(trim((string) ($record->{$columns['product_code']} ?? '')));
if ($productQuantity <= 0 || $productAmount <= 0 || $productCode === '') {
continue;
}
$rows[] = [
'online_order_no' => trim((string) ($record->{$columns['online_order_no']} ?? '')),
'shop_name' => trim((string) ($record->{$columns['shop_name']} ?? '')),
'shop_sku_id' => trim((string) ($record->{$columns['shop_product_code']} ?? '')),
'shop_i_id' => trim((string) ($record->{$columns['shop_style_code']} ?? '')),
'product_code' => $productCode,
'product_quantity' => $productQuantity,
'product_amount' => $productAmount,
];
}
}
return $rows;
}
/** @param array<string,mixed> $filters */
public function covers(array $filters): bool
{
$from = trim((string) ($filters['from'] ?? ''));
$start = (string) config('order_after_sales_cost.goods_profit_from', '2026-07-01');
// 更早月份由导入表负责,先按日期边界退出,避免每次查询都检查宽表同步状态。
if ($start !== '' && $from !== '' && $from < $start) {
return false;
}
if (! $this->ready()) {
return false;
}
$columns = $this->fieldColumns();
$to = trim((string) ($filters['to'] ?? ''));
$orderNo = trim((string) ($filters['order_no'] ?? ''));
$orderNos = $this->orderNumbers($orderNo);
// 只按单号(不限日期)查询:原表命中该单就用原表,否则交给导入历史数据兜底。
if ($from === '' && $to === '' && $orderNos !== []) {
$dateColumn = $columns['order_time'] ?? null;
foreach (['online_order_no', 'internal_order_no'] as $field) {
if (! isset($columns[$field])) {
continue;
}
$values = $field === 'internal_order_no'
? array_values(array_filter($orderNos, static fn (string $value): bool => mb_strlen($value) < 12))
: $orderNos;
if ($values === []) {
continue;
}
$query = $this->connection()->table(self::MAIN_TABLE)
->whereIn($columns[$field], $values);
// 只有带订单时间的行才是完整明细;只有费用行的历史订单交给导入数据。
if ($dateColumn !== null) {
$query->whereNotNull($dateColumn)->where($dateColumn, '<>', '');
}
if ($query->limit(1)->exists()) {
return true;
}
}
return false;
}
// “是否覆盖”只看日期范围,不掺业务筛选:筛选条件由 paginate 应用,
// 筛不到就该显示空结果,而不是回退到旧订单镜像(否则筛选会被忽略)。
if ($from === '' && $to === '') {
return true;
}
if (! isset($columns['order_time'])) {
return false;
}
$expression = $this->dateExpression($columns['order_time']);
$range = $this->connection()->table(self::MAIN_TABLE)
->selectRaw("MAX({$expression}) as max_time")
->first();
if (! $range || $range->max_time === null) {
return false;
}
// 起始日受 goods_profit_from 约束,结束日按原表实际最大日期判断(今天往往晚于最后一次同步)。
$anchor = $from !== '' ? $from : $to;
return $anchor === '' || substr((string) $range->max_time, 0, 10) >= $anchor;
}
/**
* @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
{
$cacheKey = 'goods-profit-afs:page:' . sha1((string) json_encode([$filters, $page, $limit]));
return cache()->remember($cacheKey, now()->addMinutes(2), fn (): array => $this->paginateUncached($filters, $page, $limit));
}
/**
* @param array<string,mixed> $filters
* @return array{rows:list<array<string,mixed>>,has_more:bool}
*/
private function paginateUncached(array $filters, int $page, int $limit): array
{
$page = max($page, 1);
$limit = min(max($limit, 1), 100);
$columns = $this->fieldColumns();
$select = array_values(array_unique(array_merge(['id'], array_values($columns))));
$orderColumn = $columns['order_time'] ?? null;
$orderNo = trim((string) ($filters['order_no'] ?? ''));
$orderNos = $this->orderNumbers($orderNo);
if (count($orderNos) > 1) {
return $this->paginateByOrderNumbers($orderNos, $filters, $columns, $select, $page, $limit);
}
if ($orderNo !== '') {
return $this->paginateByOrderNumber($orderNo, $filters, $columns, $select, $page, $limit);
}
$query = $this->filtered($filters, $columns)->select($select);
if ($orderColumn !== null) {
$query->orderByRaw($this->dateExpression($orderColumn) . ' desc');
}
$records = $query->orderByDesc('id')->offset(($page - 1) * $limit)->limit($limit + 1)->get();
$numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', []));
$rows = [];
foreach ($records as $record) {
$row = [];
foreach ($columns as $field => $column) {
$value = $record->{$column} ?? null;
if (isset($numberKeys[$field])) {
$value = $value === null ? null : (float) $value;
}
$row[$field] = is_string($value) ? trim($value) : $value;
}
$rows[] = $row;
}
return [
'rows' => array_slice($rows, 0, $limit),
'has_more' => count($rows) > $limit,
];
}
/**
* 单号查询:分别走 online/internal 两个索引,合并后在 PHP 排序分页。
*
* @param array<string,mixed> $filters
* @param array<string,string> $columns
* @param list<string> $select
* @return array{rows:list<array<string,mixed>>,has_more:bool}
*/
private function paginateByOrderNumber(
string $orderNo,
array $filters,
array $columns,
array $select,
int $page,
int $limit,
): array {
$records = [];
$cap = max($limit * 4, 400);
foreach ($this->orderNumberFields($orderNo) as $field) {
if (! isset($columns[$field])) {
continue;
}
$query = $this->connection()->table(self::MAIN_TABLE)
->where($columns[$field], 'like', $orderNo . '%');
$this->applyDateRange($query, $filters, $columns);
// 先只取主键(走索引、窄查询),再按主键取宽行,避免宽表全列扫描。
$ids = $query->limit($cap)->pluck('id')->all();
if ($ids !== []) {
foreach ($this->connection()->table(self::MAIN_TABLE)->whereIn('id', $ids)->get($select) as $record) {
$records[(int) $record->id] = $record;
}
}
}
$timeColumn = $columns['order_time'] ?? null;
usort($records, static function ($left, $right) use ($timeColumn): int {
$leftTime = $timeColumn !== null ? strtotime(str_replace('/', '-', (string) ($left->{$timeColumn} ?? ''))) : false;
$rightTime = $timeColumn !== null ? strtotime(str_replace('/', '-', (string) ($right->{$timeColumn} ?? ''))) : false;
return [$rightTime ?: 0, (int) $right->id] <=> [$leftTime ?: 0, (int) $left->id];
});
$numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', []));
$rows = [];
foreach ($records as $record) {
$row = [];
foreach ($columns as $field => $column) {
$value = $record->{$column} ?? null;
if (isset($numberKeys[$field])) {
$value = $value === null ? null : (float) $value;
}
$row[$field] = is_string($value) ? trim($value) : $value;
}
$rows[] = $row;
}
$offset = ($page - 1) * $limit;
return [
'rows' => array_slice($rows, $offset, $limit),
'has_more' => count($rows) > $offset + $limit,
];
}
/**
* 批量单号(一行一个/逗号/空格分隔):按两列索引 IN 查询后 SQL 分页。
*
* @param list<string> $orderNos
* @param array<string,mixed> $filters
* @param array<string,string> $columns
* @param list<string> $select
* @return array{rows:list<array<string,mixed>>,has_more:bool}
*/
private function paginateByOrderNumbers(
array $orderNos,
array $filters,
array $columns,
array $select,
int $page,
int $limit,
): array {
$short = array_values(array_filter($orderNos, static fn (string $value): bool => mb_strlen($value) < 12));
$query = $this->connection()->table(self::MAIN_TABLE);
$query->where(function ($inner) use ($columns, $orderNos, $short): void {
$matched = false;
if (isset($columns['online_order_no'])) {
$inner->whereIn($columns['online_order_no'], $orderNos);
$matched = true;
}
if ($short !== [] && isset($columns['internal_order_no'])) {
$matched ? $inner->orWhereIn($columns['internal_order_no'], $short)
: $inner->whereIn($columns['internal_order_no'], $short);
$matched = true;
}
if (! $matched) {
$inner->whereRaw('1 = 0');
}
});
$this->applyDateRange($query, $filters, $columns);
// 不额外排序(走单号索引直接取这 N 条);按 id 倒序会把主键反向全表扫描。
$ids = $query->forPage($page, $limit + 1)->pluck('id')->all();
$fetched = $ids === []
? collect()
: $this->connection()->table(self::MAIN_TABLE)->whereIn('id', $ids)->get($select)->keyBy('id');
$records = array_values(array_filter(array_map(
static fn (int $id) => $fetched[$id] ?? null,
$ids,
)));
$numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', []));
$rows = [];
foreach ($records as $record) {
$row = [];
foreach ($columns as $field => $column) {
$value = $record->{$column} ?? null;
if (isset($numberKeys[$field])) {
$value = $value === null ? null : (float) $value;
}
$row[$field] = is_string($value) ? trim($value) : $value;
}
$rows[] = $row;
}
$hasMore = count($rows) > $limit;
$rows = array_slice($rows, 0, $limit);
return ['rows' => $rows, 'has_more' => $hasMore];
}
/**
* 批量单号拆成数组(与订单镜像的批量规则一致)。
*
* @return list<string>
*/
private function orderNumbers(string $raw): array
{
$raw = trim($raw);
if ($raw === '') {
return [];
}
$parts = preg_split('/[\s,;,;、]+/u', $raw) ?: [];
$values = [];
foreach ($parts as $part) {
$part = trim((string) $part);
if ($part !== '' && ! isset($values[$part])) {
$values[$part] = true;
}
}
return array_slice(array_keys($values), 0, 200);
}
/**
* @param array<string,mixed> $filters
* @param array<string,string> $columns
*/
private function filtered(array $filters, array $columns): \Illuminate\Database\Query\Builder
{
$query = $this->connection()->table(self::MAIN_TABLE);
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
$orderNo = trim((string) ($filters['order_no'] ?? ''));
$sku = trim((string) ($filters['sku_id'] ?? ''));
$shops = array_values(array_filter((array) ($filters['shop_names'] ?? []), static fn ($v) => trim((string) $v) !== ''));
// 原表的订单时间是字符串(如 2026/8/23 14:58:51,不补零),不能直接和 ISO 日期做字符串比较。
if (($from !== '' || $to !== '') && isset($columns['order_time'])) {
$expression = $this->dateExpression($columns['order_time']);
if ($from !== '') {
$query->whereRaw($expression . ' >= ?', [$from . ' 00:00:00']);
}
if ($to !== '') {
$query->whereRaw($expression . ' <= ?', [$to . ' 23:59:59']);
}
}
if ($shops !== [] && isset($columns['shop_name'])) {
$query->whereIn($columns['shop_name'], $shops);
}
if ($orderNo !== '') {
$query->where(function ($inner) use ($columns, $orderNo): void {
$matched = false;
foreach (['online_order_no', 'internal_order_no'] as $field) {
if (! isset($columns[$field])) {
continue;
}
$inner->orWhere($columns[$field], 'like', $orderNo . '%');
$matched = true;
}
if (! $matched) {
$inner->whereRaw('1 = 0');
}
});
}
if ($sku !== '' && isset($columns['product_code'])) {
$query->where($columns['product_code'], 'like', '%' . addcslashes($sku, '%_\\') . '%');
}
return $query;
}
/**
* @param array<string,mixed> $filters
* @param array<string,string> $columns
*/
private function applyDateRange(\Illuminate\Database\Query\Builder $query, array $filters, array $columns): void
{
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
if (($from === '' && $to === '') || ! isset($columns['order_time'])) {
return;
}
$expression = $this->dateExpression($columns['order_time']);
if ($from !== '') {
$query->whereRaw($expression . ' >= ?', [$from . ' 00:00:00']);
}
if ($to !== '') {
$query->whereRaw($expression . ' <= ?', [$to . ' 23:59:59']);
}
}
private function dateExpression(string $column): string
{
// Dynamic schemas can move the source field. Keep the original parser
// until the matching additive lookup has been installed.
if ($column === 'undefined7') {
$this->hasDateLookup ??= Schema::connection($this->connectionName())
->hasColumn(self::MAIN_TABLE, GoodsProfitAfsLookupSchema::DATE_COLUMN);
if ($this->hasDateLookup) {
return '`' . GoodsProfitAfsLookupSchema::DATE_COLUMN . '`';
}
}
return "STR_TO_DATE(REPLACE(`{$column}`, '/', '-'), '%Y-%m-%d %H:%i:%s')";
}
private function connection(): \Illuminate\Database\ConnectionInterface
{
return DB::connection($this->connectionName());
}
private function connectionName(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
private static function normalizeLabel(string $label): string
{
$label = str_replace(["\u{3000}", "\u{00A0}"], ' ', $label);
$label = preg_replace('/\s+/u', ' ', $label) ?? $label;
return trim($label);
}
/**
* 线上订单号和内部订单号长度差很多(19 位 vs 7~8 位)。长单号只查线上订单号,
* 避免用内部订单号列做 `like '长单号%'` 的全表扫描。
*
* @return list<string>
*/
private function orderNumberFields(string $orderNo): array
{
return mb_strlen($orderNo) >= 12
? ['online_order_no']
: ['online_order_no', 'internal_order_no'];
}
}