CaiWuXiTong/modules/EcommerceFinance/Repositories/GoodsProfitAfsRepository.php
chenxi 01104d722d 商品成本:补充售后镜像平台SKU映射 (#512)
历史映射缺失时使用售后镜像唯一内部编码,多候选继续待核验。
2026-10-24 18:49:19 +08:00

449 lines
16 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 array<string,mixed> $filters */
public function covers(array $filters): bool
{
if (! $this->ready()) {
return false;
}
$columns = $this->fieldColumns();
$orderNo = trim((string) ($filters['order_no'] ?? ''));
// 单号查询走各自列的索引,避免 undefined2 OR undefined1 触发全表扫描。
if ($orderNo !== '') {
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);
if ($query->limit(1)->exists()) {
return true;
}
}
return false;
}
return $this->filtered($filters, $columns)->limit(1)->exists();
}
/**
* @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);
$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'] ?? ''));
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)
->select($select)
->where($columns[$field], 'like', $orderNo . '%');
$this->applyDateRange($query, $filters, $columns);
foreach ($query->limit($cap)->get() 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,
];
}
/**
* @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'];
}
}