- 把「店铺名称:可点选的多选 + 模糊搜索」抽成公共组件 partials/order-data-shop-picker.blade.php,订单售后费用明细改为引用它, 商品推广费用明细用同一个控件(原来是一个纯文本输入框)。 - 推广页提交 shop_name[](多个取并集),后端镜像查询和非淘系旧查询都改成 按多个店铺 OR 匹配,仍兼容原来的单个 shop_name。 - 推广表的店铺名带下划线(天猫_卓禾家具旗舰店),和订单镜像的「天猫-…」 不是同一套命名,所以候选单独取自 ops_item_sales_cost_ratio 当前统计日期 出现过的店铺,按日期缓存 24 小时(单次约 0.4 秒、约 400 个店铺)。
712 lines
30 KiB
PHP
712 lines
30 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Repositories;
|
||
|
||
use Illuminate\Database\ConnectionInterface;
|
||
use Illuminate\Database\Query\Builder;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||
use Throwable;
|
||
|
||
class OrderDataMirrorRepository
|
||
{
|
||
/** 完整店铺候选的缓存键(页面店铺多选使用)。 */
|
||
public const SHOP_NAMES_CACHE_KEY = 'order-data.shop-candidates:v2';
|
||
|
||
/** 订单号搜索一次最多解析出多少候选订单,超过就退回模糊匹配。 */
|
||
private const ORDER_NUMBER_CANDIDATE_LIMIT = 500;
|
||
|
||
private const DATASETS = [
|
||
'operations_replica.rpt.jst_Orders',
|
||
'operations_replica.rpt.jst_OrderItem',
|
||
'operations_replica.rpt.jst_sku',
|
||
'operations_replica.rpt.jst_warehouseInfo',
|
||
'operations_replica.rpt.jst_returnAndRefundInfo',
|
||
'operations_replica.rpt.jst_returnAndRefundItem',
|
||
];
|
||
|
||
private const PROMOTION_DATASETS = [
|
||
'operations_replica.rpt.ads_itemid_sales_cost_ratio_v2_di',
|
||
];
|
||
|
||
private readonly ConnectionInterface $connection;
|
||
|
||
public function __construct(
|
||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||
private readonly GenericBusinessMirrorRepository $mirror,
|
||
) {
|
||
$this->connection = $mirror->connection();
|
||
}
|
||
|
||
public function ready(): bool
|
||
{
|
||
return $this->datasetsReady(self::DATASETS);
|
||
}
|
||
|
||
/**
|
||
* 订单镜像里出现过的店铺名称,按名称排序;供页面上的店铺多选候选使用。
|
||
*
|
||
* @return array<int,string>
|
||
*/
|
||
public function shopNames(): array
|
||
{
|
||
if (is_array($cached = Cache::get(self::SHOP_NAMES_CACHE_KEY))) {
|
||
return array_values($cached);
|
||
}
|
||
|
||
// 完整候选要扫订单宽表(shop_name 是 LONGTEXT,非淘系约 30 秒、淘系约
|
||
// 2 分钟),不能卡在页面请求里。冷缓存时先返回毫秒级的店铺目录,
|
||
// 完整列表放到响应结束后算好再缓存 24 小时。
|
||
$this->warmShopNamesCache();
|
||
|
||
$directory = $this->shopDirectoryNames();
|
||
|
||
return $directory !== [] ? $directory : $this->computedShopNames();
|
||
}
|
||
|
||
/** 聚水潭店铺目录(约 1300 行):毫秒级返回,作为冷缓存时的候选兜底。 */
|
||
private function shopDirectoryNames(): array
|
||
{
|
||
try {
|
||
$rows = $this->connection->table('ops_jst_shop_info')
|
||
->select('shop_name')
|
||
->distinct()
|
||
->whereNotNull('shop_name')
|
||
->where('shop_name', '<>', '')
|
||
->pluck('shop_name');
|
||
} catch (Throwable $exception) {
|
||
report($exception);
|
||
|
||
return [];
|
||
}
|
||
|
||
return $this->normalizeShopNames($rows);
|
||
}
|
||
|
||
/**
|
||
* 商品推广费用明细的店铺候选:指定统计日期出现过的店铺。
|
||
*
|
||
* 推广表的店铺名带下划线("天猫_卓禾家具旗舰店"),和订单镜像的"天猫-…"不是同一套
|
||
* 命名,不能复用订单店铺候选;按日期过滤后有 400 行左右,结果缓存 24 小时。
|
||
*
|
||
* @return array<int,string>
|
||
*/
|
||
public function promotionShopNames(string $date): array
|
||
{
|
||
$date = trim($date);
|
||
if ($date === '') {
|
||
return [];
|
||
}
|
||
|
||
$key = 'order-data.promotion.shop-options:' . $date;
|
||
if (is_array($cached = Cache::get($key))) {
|
||
return array_values($cached);
|
||
}
|
||
|
||
try {
|
||
$names = $this->connection->table('ops_item_sales_cost_ratio')
|
||
->select('shop_name')
|
||
->distinct()
|
||
->where('stat_date', '>=', $date . ' 00:00:00')
|
||
->where('stat_date', '<', date('Y-m-d 00:00:00', strtotime($date . ' +1 day')))
|
||
->whereNotNull('shop_name')
|
||
->where('shop_name', '<>', '')
|
||
->orderBy('shop_name')
|
||
->pluck('shop_name');
|
||
} catch (Throwable $exception) {
|
||
report($exception);
|
||
|
||
return [];
|
||
}
|
||
|
||
$names = $this->normalizeShopNames($names);
|
||
if ($names !== []) {
|
||
Cache::put($key, $names, now()->addHours(24));
|
||
}
|
||
|
||
return $names;
|
||
}
|
||
|
||
/**
|
||
* 店铺筛选支持多选(shop_name[]=A&shop_name[]=B),也兼容原来的单个 shop_name。
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private function selectedShopNames(array $filters): array
|
||
{
|
||
$values = $filters['shop_names'] ?? [];
|
||
if (! is_array($values) || $values === []) {
|
||
$values = ($filters['shop_name'] ?? '') !== '' ? [$filters['shop_name']] : [];
|
||
}
|
||
|
||
$names = [];
|
||
foreach ($values as $value) {
|
||
if (! is_scalar($value)) {
|
||
continue;
|
||
}
|
||
|
||
$name = trim((string) $value);
|
||
if ($name !== '') {
|
||
$names[$name] = true;
|
||
}
|
||
|
||
// 与页面上的多选上限一致,避免拼出过长的 WHERE。
|
||
if (count($names) >= 50) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return array_keys($names);
|
||
}
|
||
|
||
private function warmShopNamesCache(): void
|
||
{
|
||
app()->terminating(function (): void {
|
||
$lock = Cache::lock(self::SHOP_NAMES_CACHE_KEY . ':warm', 300);
|
||
if (! $lock->get()) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$names = $this->computedShopNames();
|
||
if ($names !== []) {
|
||
Cache::put(self::SHOP_NAMES_CACHE_KEY, $names, now()->addHours(24));
|
||
}
|
||
} catch (Throwable $exception) {
|
||
report($exception);
|
||
} finally {
|
||
$lock->release();
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 非淘系 + 淘系两个来源合并去重(淘系店铺名形如"天猫-xx/淘宝-xx")。
|
||
*
|
||
* 分开各自 DISTINCT 再在 PHP 合并:UNION 会让 MariaDB 把两个上百万行的结果
|
||
* 一起物化去重再排序,单次要两三分钟。这里只在响应结束后或缓存不可用时执行。
|
||
*
|
||
* @return array<int,string>
|
||
*/
|
||
private function computedShopNames(): array
|
||
{
|
||
$names = [];
|
||
|
||
foreach (['ops_jst_orders', 'ops_jst_qmcust_orders'] as $table) {
|
||
$rows = $this->connection->table($table)
|
||
->select('shop_name')
|
||
->distinct()
|
||
->whereNotNull('shop_name')
|
||
->where('shop_name', '<>', '')
|
||
->pluck('shop_name');
|
||
|
||
foreach ($this->normalizeShopNames($rows) as $name) {
|
||
$names[$name] = true;
|
||
}
|
||
}
|
||
|
||
$names = array_keys($names);
|
||
sort($names, SORT_STRING);
|
||
|
||
return $names;
|
||
}
|
||
|
||
/** @param iterable<mixed> $rows @return array<int,string> */
|
||
private function normalizeShopNames(iterable $rows): array
|
||
{
|
||
$names = [];
|
||
|
||
foreach ($rows as $name) {
|
||
$name = trim((string) $name);
|
||
if ($name !== '') {
|
||
$names[$name] = true;
|
||
}
|
||
}
|
||
|
||
$names = array_keys($names);
|
||
sort($names, SORT_STRING);
|
||
|
||
return $names;
|
||
}
|
||
|
||
public function promotionReady(): bool
|
||
{
|
||
return $this->datasetsReady(self::PROMOTION_DATASETS);
|
||
}
|
||
|
||
private function datasetsReady(array $datasets): bool
|
||
{
|
||
try {
|
||
foreach ($datasets as $dataset) {
|
||
if (! $this->mirror->ready($this->datasets->get($dataset))) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
} catch (\Throwable) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** @return array{rows:array<int,array<string,mixed>>} */
|
||
public function afterSales(array $filters, int $page, int $limit): array
|
||
{
|
||
$limit = min(max($limit, 1), 100);
|
||
// 两套来源各取够当前页需要的行数再合并,避免把上千万行整体物化后再排序。
|
||
$perBranch = ($page * $limit) + 1;
|
||
$rows = $this->baseQuery($filters, $perBranch)
|
||
// 跨表追加 i.oi_id 会让 MariaDB 对日期范围内全部订单建立临时表
|
||
// 再 filesort;o_id 已唯一确定订单,明细通过 (o_id, Id) 索引稳定读取。
|
||
->orderByDesc('order_time')->orderByDesc('internal_order_no')
|
||
->offset((max($page, 1) - 1) * $limit)
|
||
->limit($limit + 1)
|
||
->get()->map(static fn (object $row): array => (array) $row)->all();
|
||
|
||
$this->hydrateSkus($rows);
|
||
$rows = array_map(fn (array $row): array => $this->baseRow($row), $rows);
|
||
$this->hydrateRefunds($rows);
|
||
|
||
return ['rows' => $rows];
|
||
}
|
||
|
||
/** @return array{rows:array<int,array<string,mixed>>,date:?string} */
|
||
public function promotionExpenses(array $filters, int $page, int $limit): array
|
||
{
|
||
$limit = min(max($limit, 1), 100);
|
||
$date = trim((string) ($filters['date'] ?? ''));
|
||
if ($date === '') {
|
||
$date = (string) ($this->connection->table('ops_item_sales_cost_ratio')->max('stat_date') ?? '');
|
||
$date = substr($date, 0, 10);
|
||
}
|
||
if ($date === '') {
|
||
return ['rows' => [], 'date' => null];
|
||
}
|
||
$query = $this->connection->table('ops_item_sales_cost_ratio as c')
|
||
->where('c.stat_date', '>=', $date . ' 00:00:00')
|
||
->where('c.stat_date', '<', date('Y-m-d 00:00:00', strtotime($date . ' +1 day')))
|
||
->where('c.push_fee', '<>', 0);
|
||
$shopNames = $this->selectedShopNames($filters);
|
||
if ($shopNames !== []) {
|
||
// 多店铺:每个店铺做一次模糊匹配再取并集,和订单售后费用明细的口径一致。
|
||
$query->where(function (Builder $shop) use ($shopNames): void {
|
||
foreach ($shopNames as $name) {
|
||
$shop->orWhereRaw("c.shop_name LIKE ? ESCAPE '!'", [$this->contains($name)]);
|
||
}
|
||
});
|
||
}
|
||
if (($filters['sku_id'] ?? '') !== '') {
|
||
$needle = $this->contains((string) $filters['sku_id']);
|
||
// jst_skumap 的业务编码是 LONGTEXT 且有两千多万行,不能在页面
|
||
// 请求中做模糊关联;推广账单自身的 item_id 是稳定可筛选字段。
|
||
$query->whereRaw("c.item_id LIKE ? ESCAPE '!'", [$needle]);
|
||
}
|
||
$rows = $query->select(['c.shop_name', 'c.item_id as shop_style_code', 'c.push_fee as promotion_fee'])
|
||
->orderByDesc('c.push_fee')->orderBy('c.shop_name')->orderBy('c.item_id')
|
||
->offset((max($page, 1) - 1) * $limit)->limit($limit + 1)
|
||
->get()->map(static fn (object $row): array => (array) $row)->all();
|
||
foreach ($rows as &$row) {
|
||
$row['product_code'] = null;
|
||
$row['style_code'] = null;
|
||
$row['shop_product_code'] = $row['shop_style_code'];
|
||
$row['online_color_spec'] = null;
|
||
$row['promotion_fee'] = round((float) $row['promotion_fee'], 2);
|
||
}
|
||
unset($row);
|
||
|
||
return ['rows' => $rows, 'date' => $date];
|
||
}
|
||
|
||
private function baseQuery(array $filters, int $perBranch): Builder
|
||
{
|
||
// 同一份报表口径要同时覆盖两个来源:聚水潭非淘系订单(jst_Orders 系列)和
|
||
// 淘系(天猫/淘宝)订单(jst_qmcust 系列)。两边的列名基本一致,淘系明细没有
|
||
// send_warehouse,用订单上的 wms_co_id 兜底;source_branch 用于后续按来源补售后。
|
||
//
|
||
// 筛选条件必须分别下推到两个分支:否则 MariaDB 会先合并上千万行再筛,
|
||
// 既用不上各自订单日期索引,也会把连接拖到超时。
|
||
//
|
||
// 订单号先去两套来源里解析成候选 o_id(单表、走前缀索引),再让分支按 o_id
|
||
// 过滤;直接在订单和明细关联后做 %x% 匹配时优化器会放弃索引,淘系那套
|
||
// 600 多万行明细要全表扫描,实测 120 秒都跑不完。
|
||
$orderCandidates = $this->orderNumberCandidates((string) ($filters['order_no'] ?? ''));
|
||
|
||
$plain = $this->sourceQuery('ops_jst_orders', 'ops_jst_order_item', 'COALESCE(i.send_warehouse, o.wms_co_id)', 'i.send_warehouse', 'plain');
|
||
$this->applyFilters($plain, $filters, $orderCandidates['plain'] ?? null);
|
||
$plain->orderByDesc('o.order_date')->orderByDesc('o.o_id')->limit($perBranch);
|
||
|
||
$taobao = $this->sourceQuery('ops_jst_qmcust_orders', 'ops_jst_qmcust_order_items', 'o.wms_co_id', 'o.wms_co_id', 'taobao');
|
||
$this->applyFilters($taobao, $filters, $orderCandidates['taobao'] ?? null);
|
||
$taobao->orderByDesc('o.order_date')->orderByDesc('o.o_id')->limit($perBranch);
|
||
|
||
return $this->connection->query()->fromSub(
|
||
$plain->unionAll($taobao),
|
||
'u',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 把订单号解析成两套来源各自的候选 o_id。
|
||
*
|
||
* 订单号字段(内部/线上/子订单号)在镜像里都有前缀索引,等值或"从头匹配"能走索引;
|
||
* 返回 null 表示不能用快路径(订单号太短、候选过多,或本身就是模糊匹配),
|
||
* 调用方退回原来的 %x% 匹配。
|
||
*
|
||
* @return array{plain:list<int>,taobao:list<int>}|null
|
||
*/
|
||
private function orderNumberCandidates(string $raw): ?array
|
||
{
|
||
$needle = trim($raw);
|
||
if ($needle === '' || mb_strlen($needle) < 4) {
|
||
return null;
|
||
}
|
||
|
||
$prefix = $this->likePrefix($needle);
|
||
$digits = ctype_digit($needle) ? (int) $needle : null;
|
||
$candidates = [];
|
||
|
||
foreach ([
|
||
'plain' => ['ops_jst_orders', 'ops_jst_order_item'],
|
||
'taobao' => ['ops_jst_qmcust_orders', 'ops_jst_qmcust_order_items'],
|
||
] as $branch => [$ordersTable, $itemsTable]) {
|
||
$ids = [];
|
||
|
||
$orders = $this->connection->table($ordersTable)
|
||
->select('o_id')
|
||
->whereRaw("so_id LIKE ? ESCAPE '!'", [$prefix]);
|
||
if ($digits !== null) {
|
||
$orders->orWhere('o_id', '=', $digits);
|
||
}
|
||
foreach ($orders->limit(self::ORDER_NUMBER_CANDIDATE_LIMIT + 1)->pluck('o_id') as $id) {
|
||
$ids[(int) $id] = true;
|
||
}
|
||
|
||
foreach ($this->connection->table($itemsTable)
|
||
->select('o_id')
|
||
->whereRaw("outer_oi_id LIKE ? ESCAPE '!'", [$prefix])
|
||
->limit(self::ORDER_NUMBER_CANDIDATE_LIMIT + 1)
|
||
->pluck('o_id') as $id) {
|
||
$ids[(int) $id] = true;
|
||
}
|
||
|
||
if (count($ids) > self::ORDER_NUMBER_CANDIDATE_LIMIT) {
|
||
return null;
|
||
}
|
||
|
||
$candidates[$branch] = array_map('intval', array_keys($ids));
|
||
}
|
||
|
||
return $candidates;
|
||
}
|
||
|
||
private function sourceQuery(
|
||
string $ordersTable,
|
||
string $itemsTable,
|
||
string $warehouseJoinKey,
|
||
string $warehouseColumn,
|
||
string $branch,
|
||
): Builder {
|
||
return $this->connection->table($ordersTable . ' as o')
|
||
->join($itemsTable . ' as i', 'i.o_id', '=', 'o.o_id')
|
||
->leftJoin('ops_jst_warehouse_info as wh', function ($join) use ($warehouseJoinKey): void {
|
||
$join->on('wh.wms_co_id', '=', $this->connection->raw($warehouseJoinKey));
|
||
})
|
||
->select($this->columns($warehouseColumn))
|
||
->selectRaw('? as source_branch', [$branch]);
|
||
}
|
||
|
||
/** @param list<int>|null $orderIds 订单号已解析出的候选 o_id;null 表示退回模糊匹配 */
|
||
private function applyFilters(Builder $query, array $filters, ?array $orderIds = null): void
|
||
{
|
||
if (($filters['from'] ?? '') !== '') {
|
||
$query->where('o.order_date', '>=', $filters['from'] . ' 00:00:00');
|
||
}
|
||
if (($filters['to'] ?? '') !== '') {
|
||
$query->where('o.order_date', '<', date('Y-m-d 00:00:00', strtotime($filters['to'] . ' +1 day')));
|
||
}
|
||
if (($filters['order_no'] ?? '') !== '') {
|
||
if ($orderIds === null) {
|
||
$needle = $this->contains((string) $filters['order_no']);
|
||
$query->where(function (Builder $order) use ($needle): void {
|
||
$order->whereRaw("CAST(o.o_id AS CHAR) LIKE ? ESCAPE '!'", [$needle])
|
||
->orWhereRaw("o.so_id LIKE ? ESCAPE '!'", [$needle])
|
||
->orWhereRaw("i.outer_oi_id LIKE ? ESCAPE '!'", [$needle]);
|
||
});
|
||
} elseif ($orderIds === []) {
|
||
// 两套来源都没有候选订单,直接让分支返回空集,避免白扫一遍。
|
||
$query->whereRaw('1 = 0');
|
||
} else {
|
||
$query->whereIn('o.o_id', $orderIds);
|
||
}
|
||
}
|
||
$shopNames = $this->selectedShopNames($filters);
|
||
|
||
if ($shopNames !== []) {
|
||
// 多店铺:每个店铺做一个模糊匹配再取并集,选中的是完整店铺名时等同于精确匹配。
|
||
$query->where(function (Builder $shop) use ($shopNames): void {
|
||
foreach ($shopNames as $name) {
|
||
$shop->orWhereRaw("o.shop_name LIKE ? ESCAPE '!'", [$this->contains($name)]);
|
||
}
|
||
});
|
||
}
|
||
if (($filters['sku_id'] ?? '') !== '') {
|
||
$needle = $this->contains((string) $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.shop_i_id LIKE ? ESCAPE '!'", [$needle]);
|
||
});
|
||
}
|
||
}
|
||
|
||
/** @return list<string> */
|
||
private function columns(string $warehouseColumn = 'i.send_warehouse'): array
|
||
{
|
||
return [
|
||
'o.o_id as internal_order_no', 'o.so_id as online_order_no',
|
||
'i.outer_oi_id as sub_order_no', 'o.pay_date as pay_time',
|
||
'o.end_time as order_complete_time', 'o.send_date as send_time',
|
||
'o.order_date as order_time', 'o.shop_name', 'o.shop_id as shop_code',
|
||
'i.sku_id as product_code', 'i.i_id as style_code',
|
||
'i.shop_sku_id as shop_product_code', 'i.shop_i_id as shop_style_code',
|
||
'o.shop_site', 'i.properties_value as color_spec',
|
||
'i.name as online_product_name', 'i.qty as product_quantity',
|
||
'i.amount as product_amount', 'i.buyer_paid_amount as payment_amount',
|
||
'i.seller_income_amount', 'i.is_gift', 'i.src_combine_sku_id',
|
||
'o.type as order_type', 'o.labels as order_tags',
|
||
'o.free_amount as merchant_discount', 'o.freight as freight_income',
|
||
$warehouseColumn . ' as send_warehouse', 'o.wms_co_id', 'o.l_id as express_no',
|
||
'o.logistics_company as express_company', 'o.receiver_state as province',
|
||
'o.receiver_city as city', 'o.creator_name as salesperson',
|
||
'wh.name as shipping_warehouse', 'wh.remark1 as operation_cloud_warehouse',
|
||
];
|
||
}
|
||
|
||
/** @param array<int,array<string,mixed>> $rows */
|
||
private function hydrateSkus(array &$rows): void
|
||
{
|
||
$skuIds = array_values(array_unique(array_filter(array_map(
|
||
static fn (array $row): string => trim((string) ($row['product_code'] ?? '')),
|
||
$rows,
|
||
))));
|
||
$skus = $skuIds === [] ? collect() : $this->connection->table('ops_jst_sku')
|
||
->where('sku_type', 'normal')->whereIn('sku_id', $skuIds)
|
||
->orderByDesc('autoid')
|
||
->get(['sku_id', 'brand', 'category', 'vc_name', 'cost_price', 'name', 'supplier_id', 'supplier_name'])
|
||
->unique('sku_id')->keyBy('sku_id');
|
||
|
||
foreach ($rows as &$row) {
|
||
$sku = $skus->get((string) ($row['product_code'] ?? ''));
|
||
$row['brand'] = $sku->brand ?? null;
|
||
$row['category'] = $sku->category ?? null;
|
||
$row['virtual_category'] = $sku->vc_name ?? null;
|
||
$row['unit_cost'] = $sku->cost_price ?? 0;
|
||
$row['sku_name'] = $sku->name ?? null;
|
||
$row['supplier_code'] = $sku->supplier_id ?? null;
|
||
$row['supplier_name'] = $sku->supplier_name ?? null;
|
||
unset($row['sku_id']);
|
||
}
|
||
unset($row);
|
||
}
|
||
|
||
private function baseRow(array $row): array
|
||
{
|
||
$quantity = (float) ($row['product_quantity'] ?? 0);
|
||
$amount = (float) ($row['product_amount'] ?? 0);
|
||
$cost = (float) ($row['unit_cost'] ?? 0);
|
||
$payment = (float) ($row['payment_amount'] ?? $amount);
|
||
$sellerIncome = (float) ($row['seller_income_amount'] ?? 0);
|
||
$row += [
|
||
'after_sales_no' => null, 'refund_type' => null, 'question_type' => null,
|
||
'detail_return_type' => null, 'after_sales_tags' => null,
|
||
];
|
||
$row['product_name'] = ($row['sku_name'] ?? '') !== '' ? $row['sku_name'] : ($row['color_spec'] ?? null);
|
||
$row['product_quantity'] = $quantity;
|
||
$row['product_amount'] = $amount;
|
||
$row['product_cost'] = $cost * $quantity;
|
||
$row['reversal_order_cost'] = $amount < 0 ? $cost * abs($quantity) : 0.0;
|
||
$row['merchant_discount'] = (float) ($row['merchant_discount'] ?? 0);
|
||
$row['freight_income'] = (float) ($row['freight_income'] ?? 0);
|
||
$row['payment_amount'] = $payment;
|
||
$row['platform_discount'] = $sellerIncome > $payment ? $sellerIncome - $payment : 0.0;
|
||
$row['reversal_order_amount'] = $amount < 0 ? abs($amount) : null;
|
||
$row['actual_shipped_amount'] = $amount;
|
||
$row['actual_shipped_quantity'] = $quantity;
|
||
$row['actual_shipped_cost'] = $cost * $quantity;
|
||
foreach (['pre', 'post'] as $phase) {
|
||
foreach (['application_quantity', 'application_amount', 'application_cost', 'return_quantity',
|
||
'return_amount', 'return_cost', 'refund_amount', 'refund_cost', 'platform_subsidy'] as $field) {
|
||
$row[$phase . '_' . $field] = 0.0;
|
||
}
|
||
}
|
||
$row['defective_quantity'] = 0.0;
|
||
$row['is_gift'] = (int) ($row['is_gift'] ?? 0) === 1 ? '是' : '否';
|
||
$row['combine_type'] = empty($row['src_combine_sku_id']) ? '非组合' : '组合装';
|
||
$row['shipping_warehouse_code'] = $row['send_warehouse'] ?: ($row['wms_co_id'] ?? null);
|
||
$row['product_link'] = $this->productLink(
|
||
(string) ($row['shop_site'] ?? ''), (string) ($row['shop_name'] ?? ''),
|
||
(string) ($row['shop_style_code'] ?? ''),
|
||
);
|
||
foreach (['offline_customer_code', 'offline_customer_name', 'unique_code', 'registration_time',
|
||
'confirmation_time', 'warehouse_entry_time', 'classification_order',
|
||
'receiving_warehouse_code', 'receiving_warehouse'] as $field) {
|
||
$row[$field] = null;
|
||
}
|
||
foreach ($this->expenseFields() as $field) {
|
||
$row[$field] = null;
|
||
}
|
||
// 500112 冲减收入:暂以「退货红冲金额(reversal_order_amount)」作为收入冲减口径,
|
||
// 待财务确认最终取数规则后替换。其余 account_* 科目字段仍为空(科目归集未接入)。
|
||
$row['account_500112'] = $row['reversal_order_amount'] === null
|
||
? null
|
||
: round((float) $row['reversal_order_amount'], 2);
|
||
unset($row['shop_site'], $row['unit_cost'], $row['sku_name'], $row['seller_income_amount'],
|
||
$row['src_combine_sku_id'], $row['send_warehouse'], $row['wms_co_id']);
|
||
return $row;
|
||
}
|
||
|
||
/** @param array<int,array<string,mixed>> $rows */
|
||
private function hydrateRefunds(array &$rows): void
|
||
{
|
||
// 两个来源的售后表不同:非淘系读 ops_jst_return_refund_*,淘系读 ops_jst_qmcust_refund*。
|
||
// 按来源分组取数,再用 "来源:o_id" 匹配,避免两个来源的 o_id 撞号。
|
||
$idsByBranch = ['plain' => [], 'taobao' => []];
|
||
foreach ($rows as $row) {
|
||
$orderId = (string) ($row['internal_order_no'] ?? '');
|
||
if ($orderId === '') {
|
||
continue;
|
||
}
|
||
$idsByBranch[self::branchOf($row)][$orderId] = true;
|
||
}
|
||
|
||
if ($idsByBranch['plain'] === [] && $idsByBranch['taobao'] === []) {
|
||
foreach ($rows as &$row) {
|
||
unset($row['source_branch']);
|
||
}
|
||
unset($row);
|
||
|
||
return;
|
||
}
|
||
|
||
$refunds = collect();
|
||
$sources = [
|
||
'plain' => ['ops_jst_return_refund_info', 'ops_jst_return_refund_item'],
|
||
'taobao' => ['ops_jst_qmcust_refunds', 'ops_jst_qmcust_refund_items'],
|
||
];
|
||
|
||
foreach ($sources as $branch => [$infoTable, $itemTable]) {
|
||
$orderIds = array_keys($idsByBranch[$branch]);
|
||
if ($orderIds === []) {
|
||
continue;
|
||
}
|
||
|
||
$grouped = $this->connection->table($infoTable . ' as a')
|
||
->join($itemTable . ' as ri', 'ri.as_id', '=', 'a.as_id')
|
||
->whereIn('a.o_id', $orderIds)
|
||
->orderByDesc('a.as_date')->orderByDesc('a.as_id')
|
||
->get([
|
||
'a.o_id as internal_order_no', 'a.as_id as after_sales_id',
|
||
'a.outer_as_id as outside_after_sales_id', 'a.as_date as after_sales_time',
|
||
'a.type as refund_type', 'a.question_type', 'a.labels as after_sales_tags',
|
||
'a.refund as refund_amount', 'a.free_amount as platform_subsidy',
|
||
'ri.outer_oi_id as sub_order_no', 'ri.sku_id as product_code',
|
||
'ri.type as detail_return_type', 'ri.qty as application_quantity',
|
||
'ri.amount as application_amount', 'ri.r_qty as return_quantity',
|
||
'ri.shop_amount as return_amount', 'ri.defective_qty as defective_quantity',
|
||
])->groupBy('internal_order_no');
|
||
|
||
foreach ($grouped as $orderId => $items) {
|
||
$refunds->put($branch . ':' . $orderId, $items);
|
||
}
|
||
}
|
||
|
||
foreach ($rows as &$row) {
|
||
$matches = collect($refunds->get(self::branchOf($row) . ':' . ($row['internal_order_no'] ?? ''), collect()))->filter(
|
||
static function (object $refund) use ($row): bool {
|
||
return (string) ($refund->sub_order_no ?? '') !== ''
|
||
? (string) $refund->sub_order_no === (string) ($row['sub_order_no'] ?? '')
|
||
: (string) ($refund->product_code ?? '') === (string) ($row['product_code'] ?? '');
|
||
},
|
||
)->values();
|
||
$first = $matches->first();
|
||
if ($first !== null) {
|
||
$row['after_sales_no'] = $first->outside_after_sales_id ?: $first->after_sales_id;
|
||
$row['refund_type'] = $first->refund_type;
|
||
$row['question_type'] = $first->question_type;
|
||
$row['detail_return_type'] = $first->detail_return_type;
|
||
$row['after_sales_tags'] = $first->after_sales_tags;
|
||
}
|
||
$unitCost = $row['product_quantity'] == 0 ? 0 : $row['product_cost'] / $row['product_quantity'];
|
||
$preReturned = 0.0;
|
||
foreach ($matches as $refund) {
|
||
$phase = empty($row['send_time']) || (string) $refund->after_sales_time < (string) $row['send_time']
|
||
? 'pre' : 'post';
|
||
$application = (float) ($refund->application_quantity ?? 0);
|
||
$returned = (float) ($refund->return_quantity ?? 0);
|
||
$row[$phase . '_application_quantity'] += $application;
|
||
$row[$phase . '_application_amount'] += (float) ($refund->application_amount ?? 0);
|
||
$row[$phase . '_application_cost'] += $application * $unitCost;
|
||
$row[$phase . '_return_quantity'] += $returned;
|
||
$row[$phase . '_return_amount'] += (float) ($refund->return_amount ?? 0);
|
||
$row[$phase . '_return_cost'] += $returned * $unitCost;
|
||
$row[$phase . '_refund_amount'] += (float) ($refund->refund_amount ?? 0);
|
||
$row[$phase . '_refund_cost'] += ($application - $returned) * $unitCost;
|
||
$row[$phase . '_platform_subsidy'] += (float) ($refund->platform_subsidy ?? 0);
|
||
$row['defective_quantity'] += (float) ($refund->defective_quantity ?? 0);
|
||
if ($phase === 'pre') {
|
||
$preReturned += $returned;
|
||
}
|
||
}
|
||
$row['actual_shipped_quantity'] = $row['product_quantity'] - $preReturned;
|
||
$row['actual_shipped_amount'] = $row['product_amount'] - $row['pre_return_amount'];
|
||
$row['actual_shipped_cost'] = $row['actual_shipped_quantity'] * $unitCost;
|
||
unset($row['source_branch']);
|
||
}
|
||
unset($row);
|
||
}
|
||
|
||
/** 判断订单行来自哪一套来源表。 */
|
||
private static function branchOf(array $row): string
|
||
{
|
||
return ($row['source_branch'] ?? 'plain') === 'taobao' ? 'taobao' : 'plain';
|
||
}
|
||
|
||
private function productLink(string $site, string $shopName, string $itemId): ?string
|
||
{
|
||
if ($itemId === '') {
|
||
return null;
|
||
}
|
||
$platform = mb_strtolower($site . ' ' . $shopName);
|
||
if (str_contains($platform, '京东') || str_contains($platform, '京喜') || str_contains($platform, 'jd')) {
|
||
return 'https://item.jd.com/' . rawurlencode($itemId) . '.html';
|
||
}
|
||
if (str_contains($platform, '抖音')) {
|
||
return 'https://haohuo.jinritemai.com/ecommerce/trade/detail/index.html?id=' . rawurlencode($itemId);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** @return list<string> */
|
||
private function expenseFields(): array
|
||
{
|
||
return [
|
||
'account_500112', 'account_60031', 'account_800101', 'account_800103',
|
||
'account_800104', 'account_800105', 'account_800106', 'account_800107',
|
||
'account_800108', 'account_800109', 'account_800110', 'account_800114',
|
||
'account_8001201', 'account_8001202', 'account_800122', 'account_800125',
|
||
'account_800127', 'account_800129', 'account_800130', 'account_800132',
|
||
'account_800304', 'account_800501', 'account_800503',
|
||
];
|
||
}
|
||
|
||
private function contains(string $value): string
|
||
{
|
||
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
|
||
}
|
||
|
||
/** 转义后的"从头匹配"模式,配合 so_id / outer_oi_id 的前缀索引使用。 */
|
||
private function likePrefix(string $value): string
|
||
{
|
||
return str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
|
||
}
|
||
}
|