订单售后费用明细:接入聚水潭「订单售后费用数据」导入明细 #421

Merged
renshen merged 1 commits from feature/renshen-after-sales-cost-import into main 2026-10-21 13:03:25 +08:00
7 changed files with 638 additions and 1 deletions

View File

@ -0,0 +1,206 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\AfterSalesCostRowRepository;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
/**
* 导入聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」Excel。
*
* 该报表已按商品编码(组合装拆成组件)给出每行和成本,导入后订单售后费用明细页面
* 在覆盖的店铺/日期范围内直接按这些明细行展示。
*/
class ImportAfterSalesCostRows extends Command
{
protected $signature = 'order-data:import-after-sales-cost {file : Excel 文件路径} {--chunk=300 : 每批写入行数}';
protected $description = '导入聚水潭「订单售后费用数据」Excel 到订单售后费用明细';
public function handle(): int
{
$path = (string) $this->argument('file');
if (! is_file($path)) {
$this->error('文件不存在:' . $path);
return self::FAILURE;
}
$headers = (array) config('order_after_sales_cost.headers', []);
$numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', []));
if ($headers === []) {
$this->error('缺少表头映射:config/order_after_sales_cost.php');
return self::FAILURE;
}
$this->info('读取文件:' . $path);
try {
$reader = IOFactory::createReaderForFile($path);
$reader->setReadDataOnly(true);
$sheet = $reader->load($path)->getActiveSheet();
} catch (\Throwable $exception) {
$this->error('读取失败:' . $exception->getMessage());
return self::FAILURE;
}
$rowIterator = $sheet->getRowIterator();
$rowIterator->rewind();
$rawHeaders = [];
$first = true;
$columns = [];
$chunkSize = max((int) $this->option('chunk'), 50);
$buffer = [];
$total = 0;
$written = 0;
$skipped = 0;
$shops = [];
$minDate = null;
$maxDate = null;
$importedAt = now()->toDateTimeString();
foreach ($rowIterator as $row) {
$cells = [];
foreach ($row->getCellIterator() as $cell) {
$cells[] = $cell->getValue();
}
if ($first) {
$first = false;
$rawHeaders = array_map(static fn ($v) => self::normalizeHeader((string) $v), $cells);
foreach ($rawHeaders as $index => $header) {
if ($header !== '' && isset($headers[$header])) {
$columns[$index] = $headers[$header];
}
}
if ($columns === []) {
$this->error('表头和配置的映射对不上,请检查文件或 config/order_after_sales_cost.php');
return self::FAILURE;
}
continue;
}
$total++;
$payload = [];
foreach ($columns as $index => $key) {
$value = $cells[$index] ?? null;
if (is_string($value)) {
$value = trim($value);
}
if ($value === '' || $value === null) {
continue;
}
if (isset($numberKeys[$key])) {
$value = is_numeric($value) ? (float) $value : 0.0;
} elseif (self::isDateKey($key)) {
$value = self::dateValue($value);
}
$payload[$key] = $value;
}
$shop = trim((string) ($payload['shop_name'] ?? ''));
if ($shop === '' || empty($payload['product_code'])) {
$skipped++;
continue;
}
$buffer[] = AfterSalesCostRowRepository::toRecord($payload, $importedAt);
$shops[$shop] = true;
$orderTime = $payload['order_time'] ?? null;
if (is_string($orderTime) && $orderTime !== '') {
$minDate = $minDate === null || $orderTime < $minDate ? $orderTime : $minDate;
$maxDate = $maxDate === null || $orderTime > $maxDate ? $orderTime : $maxDate;
}
if (count($buffer) >= $chunkSize) {
$written += $this->flush($buffer);
$buffer = [];
$this->output->write("\r写入 " . $written . ' 行');
}
}
if ($buffer !== []) {
$written += $this->flush($buffer);
}
$this->newLine(2);
$this->info(sprintf(
'完成:读取 %d 行,写入 %d 行,跳过 %d 行;店铺 %d 个;订单时间 %s ~ %s',
$total,
$written,
$skipped,
count($shops),
$minDate ?? '-',
$maxDate ?? '-',
));
return self::SUCCESS;
}
/** @param list<array<string,mixed>> $rows */
private function flush(array $rows): int
{
DB::table('ops_after_sales_cost_rows')->upsert(
$rows,
['row_key'],
[
'shop_name', 'shop_code', 'online_order_no', 'internal_order_no', 'product_code',
'order_time', 'product_quantity', 'product_amount', 'product_cost', 'cost_source',
'combine_type', 'payload', 'imported_at', 'updated_at',
],
);
return count($rows);
}
private static function normalizeHeader(string $header): string
{
$header = str_replace(["\u{3000}", "\u{00A0}"], ' ', $header);
$header = preg_replace('/\s+/u', ' ', $header) ?? $header;
return trim($header);
}
private static function isDateKey(string $key): bool
{
return in_array($key, [
'pay_time', 'order_complete_time', 'send_time', 'order_time', 'registration_time',
'confirmation_time', 'warehouse_entry_time',
], true);
}
private static function dateValue(mixed $value): ?string
{
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d H:i:s');
}
if (is_numeric($value) && (float) $value > 20000 && (float) $value < 80000) {
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d H:i:s');
}
$timestamp = strtotime((string) $value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
}

View File

@ -0,0 +1,140 @@
<?php
/*
|--------------------------------------------------------------------------
| 订单售后费用明细(聚水潭「订单售后费用数据」导出)
|--------------------------------------------------------------------------
|
| 聚水潭导出文件是中文表头,这里把表头映射成页面字段键,导入时按这个映射入库。
| 未列出的表头会忽略;新增表头时补在这里即可。
*/
return [
'headers' => [
'内部订单号' => 'internal_order_no',
'线上订单号' => 'online_order_no',
'子订单号' => 'sub_order_no',
'售后单号' => 'after_sales_no',
'支付时间' => 'pay_time',
'订单完成时间' => 'order_complete_time',
'发货时间' => 'send_time',
'订单时间' => 'order_time',
'店铺名称' => 'shop_name',
'店铺编码' => 'shop_code',
'商品编码' => 'product_code',
'款式编码(参考)' => 'style_code',
'店铺商品编码' => 'shop_product_code',
'店铺款式编码' => 'shop_style_code',
'链接' => 'product_link',
"链接\u{3000}" => 'product_link',
'品牌' => 'brand',
'分类' => 'category',
'虚拟分类' => 'virtual_category',
'颜色及规格' => 'color_spec',
'商品名称' => 'product_name',
'唯一码' => 'unique_code',
'商品数量' => 'product_quantity',
'商品金额' => 'product_amount',
'商品成本' => 'product_cost',
'成本来源' => 'cost_source',
'其中:红冲订单成本' => 'reversal_order_cost',
'手动调整收入' => 'manual_adjust_income',
'手动调整成本' => 'manual_adjust_cost',
'商家优惠' => 'merchant_discount',
'运费收入' => 'freight_income',
'付款金额' => 'payment_amount',
'其中:平台优惠' => 'platform_discount',
'其中:红冲订单金额' => 'reversal_order_amount',
'实发金额' => 'actual_shipped_amount',
'实发数量' => 'actual_shipped_quantity',
'实发成本' => 'actual_shipped_cost',
'订单类型' => 'order_type',
'登记时间' => 'registration_time',
'确认时间' => 'confirmation_time',
'进仓时间' => 'warehouse_entry_time',
'线上商品名称' => 'online_product_name',
'发货前申请数量' => 'pre_application_quantity',
'发货前申请金额' => 'pre_application_amount',
'发货前申请成本' => 'pre_application_cost',
'发货前退回数量' => 'pre_return_quantity',
'发货前退回金额' => 'pre_return_amount',
'发货前退回成本' => 'pre_return_cost',
'发货前退款金额' => 'pre_refund_amount',
'发货前退款成本' => 'pre_refund_cost',
'发货前退平台补贴' => 'pre_platform_subsidy',
'发货后申请数量' => 'post_application_quantity',
'发货后申请金额' => 'post_application_amount',
'发货后申请成本' => 'post_application_cost',
'发货后退回数量' => 'post_return_quantity',
'发货后退回金额' => 'post_return_amount',
'发货后退回成本' => 'post_return_cost',
'发货后退款金额' => 'post_refund_amount',
'发货后退款成本' => 'post_refund_cost',
'发货后退平台补贴' => 'post_platform_subsidy',
'其中:次品商品数量' => 'defective_quantity',
'退款类型' => 'refund_type',
'问题类型' => 'question_type',
'明细退货类型' => 'detail_return_type',
'分类单' => 'classification_order',
'赠品' => 'is_gift',
'组合装' => 'combine_type',
'订单标签' => 'order_tags',
'售后单标签' => 'after_sales_tags',
'商品标签' => 'item_tags',
'发货仓编号' => 'shipping_warehouse_code',
'发货仓库' => 'shipping_warehouse',
'运营云仓' => 'operation_cloud_warehouse',
'收货仓编号' => 'receiving_warehouse_code',
'收货仓' => 'receiving_warehouse',
'供销商编号' => 'supplier_code',
'供销商' => 'supplier_name',
'快递单号' => 'express_no',
'快递公司' => 'express_company',
'省' => 'province',
'市' => 'city',
'业务员' => 'salesperson',
'线下客户编号' => 'offline_customer_code',
'线下客户名称' => 'offline_customer_name',
'500112冲减收入' => 'account_500112',
'60031erp快递费' => 'account_60031',
'800101其他服务费' => 'account_800101',
'800103其他活动费用' => 'account_800103',
'800104违约赔付' => 'account_800104',
'800105平台佣金' => 'account_800105',
'800106保证金' => 'account_800106',
'800107推广充值' => 'account_800107',
'800108品牌新享' => 'account_800108',
'800109淘宝客/多多进宝' => 'account_800109',
'800110运费险' => 'account_800110',
'800114公益宝贝' => 'account_800114',
'8001201直通车/钻展/快车等' => 'account_8001201',
'8001202推广红包冲减' => 'account_8001202',
'800122售后补偿' => 'account_800122',
'800125红包' => 'account_800125',
'800127退货运费' => 'account_800127',
'800129退款' => 'account_800129',
'800130订单' => 'account_800130',
'800132其他费用' => 'account_800132',
'800133品牌新享-收入' => 'account_800133',
'800304售后补偿-聚工单' => 'account_800304',
'800501种菜佣金' => 'account_800501',
'800503发空包/赠品费用' => 'account_800503',
],
'number_keys' => [
'product_quantity', 'product_amount', 'product_cost', 'reversal_order_cost',
'manual_adjust_income', 'manual_adjust_cost', 'merchant_discount', 'freight_income',
'payment_amount', 'platform_discount', 'reversal_order_amount', 'actual_shipped_amount',
'actual_shipped_quantity', 'actual_shipped_cost', 'pre_application_quantity',
'pre_application_amount', 'pre_application_cost', 'pre_return_quantity', 'pre_return_amount',
'pre_return_cost', 'pre_refund_amount', 'pre_refund_cost', 'pre_platform_subsidy',
'post_application_quantity', 'post_application_amount', 'post_application_cost',
'post_return_quantity', 'post_return_amount', 'post_return_cost', 'post_refund_amount',
'post_refund_cost', 'post_platform_subsidy', 'defective_quantity',
'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_800133', 'account_800304', 'account_800501', 'account_800503',
],
];

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」导入明细。
*
* 聚水潭报表已按商品编码(组合装拆成组件)给出行和成本,这里原样保留每行字段,
* 订单售后费用明细页面在导入数据覆盖的店铺/日期范围内优先读这张表。
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('ops_after_sales_cost_rows', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('row_key', 40)->unique();
$table->string('shop_name', 191);
$table->string('shop_code', 64)->nullable();
$table->string('online_order_no', 64)->nullable();
$table->string('internal_order_no', 64)->nullable();
$table->string('product_code', 191)->nullable();
$table->dateTime('order_time')->nullable();
$table->decimal('product_quantity', 14, 2)->default(0);
$table->decimal('product_amount', 16, 2)->default(0);
$table->decimal('product_cost', 16, 2)->default(0);
$table->string('cost_source', 32)->nullable();
$table->string('combine_type', 32)->nullable();
$table->json('payload');
$table->timestamp('imported_at')->nullable();
$table->timestamps();
$table->index(['shop_name', 'order_time']);
$table->index('online_order_no');
$table->index('product_code');
});
}
public function down(): void
{
Schema::dropIfExists('ops_after_sales_cost_rows');
}
};

View File

@ -0,0 +1,166 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 订单售后费用明细(聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」导入)。
*
* 聚水潭报表已经按商品编码(组合装拆成组件)给出行和成本,页面直接读这张导入表,
* 不再从订单明细里自己拆组合装。
*/
class AfterSalesCostRowRepository
{
private const TABLE = 'ops_after_sales_cost_rows';
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;
}
return $this->filtered($filters)->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);
$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,
];
}
/**
* 导入一行(命令内部使用,批量写入)。
*
* @param array<string,mixed> $payload
*/
public static function toRecord(array $payload, ?string $importedAt = null): array
{
$rowKey = sha1(implode('|', [
(string) ($payload['shop_name'] ?? ''),
(string) ($payload['online_order_no'] ?? ''),
(string) ($payload['internal_order_no'] ?? ''),
(string) ($payload['sub_order_no'] ?? ''),
(string) ($payload['after_sales_no'] ?? ''),
(string) ($payload['product_code'] ?? ''),
(string) ($payload['product_quantity'] ?? ''),
(string) ($payload['product_amount'] ?? ''),
]));
$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),
'order_time' => self::dateTime($payload['order_time'] ?? null),
'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'] ?? ''));
$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) !== ''));
if ($from !== '') {
$query->where('order_time', '>=', $from . ' 00:00:00');
}
if ($to !== '') {
$query->where('order_time', '<=', $to . ' 23:59:59');
}
if ($shops !== []) {
$query->whereIn('shop_name', $shops);
}
if ($orderNo !== '') {
$query->where(function (Builder $inner) use ($orderNo): void {
$inner->where('online_order_no', 'like', $orderNo . '%')
->orWhere('internal_order_no', 'like', $orderNo . '%')
->orWhere('internal_order_no', '=', $orderNo);
});
}
if ($sku !== '') {
$query->where('product_code', 'like', '%' . addcslashes($sku, '%_\\') . '%');
}
return $query;
}
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);
}
}

View File

@ -168,6 +168,21 @@ class OrderDataPageData
public function afterSalesRows(array $filters, int $page, int $limit): array
{
$limit = min(max($limit, 1), 100);
// 聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」导入的明细:
// 已经是按商品编码(组合装拆成组件)的行,覆盖到的店铺/日期范围优先使用。
$imported = app(\Modules\EcommerceFinance\Repositories\AfterSalesCostRowRepository::class);
if ($imported->covers($filters)) {
$result = $imported->paginate($filters, $page, $limit);
return [
'rows' => $result['rows'],
'has_more' => $result['has_more'],
'status' => ['X-Caiwu-Data-Status' => 'imported'],
];
}
$mirror = $this->mirrorOrders ?? app(OrderDataMirrorRepository::class);
if ($mirror->ready()) {
$result = $mirror->afterSales($filters, $page, $limit);

View File

@ -307,7 +307,7 @@
<div class="upstream-data-pagination">
<span>
当前页 {{ $rows?->count() ?? 0 }} 条
@if ($status) · {{ strtoupper($status) }} @endif
@if ($status) · {{ $status === 'imported' ? '聚水潭导入' : strtoupper($status) }} @endif
</span>
<div class="upstream-data-pagination__controls">
<a

View File

@ -0,0 +1,65 @@
<?php
namespace Tests\Feature\Business;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\AfterSalesCostRowRepository;
use Modules\EcommerceFinance\View\OrderDataPageData;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\Feature\FeatureTestCase;
class AfterSalesCostImportTest extends FeatureTestCase
{
public function test_imported_rows_are_served_before_the_mirror(): void
{
DB::table('ops_after_sales_cost_rows')->insert(AfterSalesCostRowRepository::toRecord([
'shop_name' => '天猫-亿家达旗舰店',
'shop_code' => '18526685',
'online_order_no' => '5120748758529014849',
'internal_order_no' => '8064260',
'product_code' => 'SJ038020N1410V02',
'order_time' => '2026-06-21 18:00:00',
'product_quantity' => 1,
'product_amount' => 385.43,
'product_cost' => 110.53,
'cost_source' => '商品成本',
'combine_type' => '组合单品',
]));
$result = app(OrderDataPageData::class)->afterSalesRows([
'from' => '2026-06-01',
'to' => '2026-06-30',
'shop_names' => ['天猫-亿家达旗舰店'],
'order_no' => '',
'sku_id' => '',
], 1, 20);
$this->assertSame('imported', $result['status']['X-Caiwu-Data-Status']);
$this->assertCount(1, $result['rows']);
$this->assertSame('SJ038020N1410V02', $result['rows'][0]['product_code']);
$this->assertEquals(110.53, $result['rows'][0]['product_cost']);
}
public function test_import_command_reads_the_jst_export_file(): void
{
$path = tempnam(sys_get_temp_dir(), 'jst') . '.xlsx';
$sheet = new Spreadsheet();
$sheet->getActiveSheet()->fromArray([
['内部订单号', '线上订单号', '店铺名称', '商品编码', '商品数量', '商品金额', '商品成本', '成本来源', '组合装', '订单时间'],
['8064260', '5120748758529014849', '天猫-亿家达旗舰店', 'SJ038051N1410V01', 1, 14.54, 4.17, '商品成本', '组合单品', '2026-06-21 18:00:00'],
]);
(new Xlsx($sheet))->save($path);
$this->artisan('order-data:import-after-sales-cost', ['file' => $path])->assertSuccessful();
$this->assertDatabaseHas('ops_after_sales_cost_rows', [
'shop_name' => '天猫-亿家达旗舰店',
'product_code' => 'SJ038051N1410V01',
'cost_source' => '商品成本',
]);
@unlink($path);
}
}