From c81c811fde82d5afad9e9787f076366883b4b4b0 Mon Sep 17 00:00:00 2001 From: renshen <1005457301@qq.com> Date: Wed, 21 Oct 2026 13:02:43 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=A2=E5=8D=95=E5=94=AE=E5=90=8E=E8=B4=B9?= =?UTF-8?q?=E7=94=A8=E6=98=8E=E7=BB=86=EF=BC=9A=E6=8E=A5=E5=85=A5=E8=81=9A?= =?UTF-8?q?=E6=B0=B4=E6=BD=AD=E3=80=8C=E8=AE=A2=E5=8D=95=E5=94=AE=E5=90=8E?= =?UTF-8?q?=E8=B4=B9=E7=94=A8=E6=95=B0=E6=8D=AE=E3=80=8D=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E6=98=8E=E7=BB=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增导入表与 order-data:import-after-sales-cost 命令,按商品编码(组合装拆成组件)导入聚水潭导出 - 页面在导入数据覆盖的店铺/日期范围内优先读导入明细,成本直接用报表值(含成本来源) - 补充导入与页面取数测试 --- .../Commands/ImportAfterSalesCostRows.php | 206 ++++++++++++++++++ config/order_after_sales_cost.php | 140 ++++++++++++ ...21_100000_create_after_sales_cost_rows.php | 45 ++++ .../AfterSalesCostRowRepository.php | 166 ++++++++++++++ .../View/OrderDataPageData.php | 15 ++ .../order-data/after-sales-expenses.blade.php | 2 +- .../Business/AfterSalesCostImportTest.php | 65 ++++++ 7 files changed, 638 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/ImportAfterSalesCostRows.php create mode 100644 config/order_after_sales_cost.php create mode 100644 database/migrations/2026_10_21_100000_create_after_sales_cost_rows.php create mode 100644 modules/EcommerceFinance/Repositories/AfterSalesCostRowRepository.php create mode 100644 tests/Feature/Business/AfterSalesCostImportTest.php diff --git a/app/Console/Commands/ImportAfterSalesCostRows.php b/app/Console/Commands/ImportAfterSalesCostRows.php new file mode 100644 index 00000000..687f84a7 --- /dev/null +++ b/app/Console/Commands/ImportAfterSalesCostRows.php @@ -0,0 +1,206 @@ +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> $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); + } +} diff --git a/config/order_after_sales_cost.php b/config/order_after_sales_cost.php new file mode 100644 index 00000000..77efa553 --- /dev/null +++ b/config/order_after_sales_cost.php @@ -0,0 +1,140 @@ + [ + '内部订单号' => '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', + ], +]; diff --git a/database/migrations/2026_10_21_100000_create_after_sales_cost_rows.php b/database/migrations/2026_10_21_100000_create_after_sales_cost_rows.php new file mode 100644 index 00000000..52d9146b --- /dev/null +++ b/database/migrations/2026_10_21_100000_create_after_sales_cost_rows.php @@ -0,0 +1,45 @@ +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'); + } +}; diff --git a/modules/EcommerceFinance/Repositories/AfterSalesCostRowRepository.php b/modules/EcommerceFinance/Repositories/AfterSalesCostRowRepository.php new file mode 100644 index 00000000..019131a1 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/AfterSalesCostRowRepository.php @@ -0,0 +1,166 @@ +available === null) { + try { + $this->available = Schema::hasTable(self::TABLE) + && DB::table(self::TABLE)->exists(); + } catch (\Throwable) { + $this->available = false; + } + } + + return $this->available; + } + + /** + * 导入数据是否覆盖本次筛选(有至少一行命中即视为覆盖)。 + * + * @param array $filters + */ + public function covers(array $filters): bool + { + if (! $this->available()) { + return false; + } + + return $this->filtered($filters)->limit(1)->exists(); + } + + /** + * @param array $filters + * @return array{rows:list>,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 $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 $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); + } +} diff --git a/modules/EcommerceFinance/View/OrderDataPageData.php b/modules/EcommerceFinance/View/OrderDataPageData.php index 88c7f2d1..c4b6bc82 100644 --- a/modules/EcommerceFinance/View/OrderDataPageData.php +++ b/modules/EcommerceFinance/View/OrderDataPageData.php @@ -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); diff --git a/resources/views/business/pages/order-data/after-sales-expenses.blade.php b/resources/views/business/pages/order-data/after-sales-expenses.blade.php index c6851ddf..49fbddc4 100644 --- a/resources/views/business/pages/order-data/after-sales-expenses.blade.php +++ b/resources/views/business/pages/order-data/after-sales-expenses.blade.php @@ -307,7 +307,7 @@
当前页 {{ $rows?->count() ?? 0 }} 条 - @if ($status) · {{ strtoupper($status) }} @endif + @if ($status) · {{ $status === 'imported' ? '聚水潭导入' : strtoupper($status) }} @endif
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); + } +} -- 2.47.2