- 新增 jst_order_labels.item_code 迁移;导入时从商品明细镜像按 o_id 写入货号(被替换/取消的明细不计入) - 「是否种菜订单」增加规则:货号含 X00006(信誉订单虚拟条码)直接判「是」 - 页面与导出新增货号列;新增 jst-order-labels:sync-item-codes 回填命令 - 测试:新增货号/种菜判定/回填命令用例,并修正导出表头过时断言
325 lines
17 KiB
PHP
325 lines
17 KiB
PHP
<?php
|
||
|
||
namespace Tests\Feature\Business;
|
||
|
||
use App\Business\JstOrderLabelImporter;
|
||
use App\Models\Auth\Permission;
|
||
use App\Models\Business\JstOrderLabel;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Schema;
|
||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||
use Tests\Feature\FeatureTestCase;
|
||
|
||
/**
|
||
* 账单流水数据 › 聚水潭订单标签明细(按标签拆行导入 + 页面 + 筛选 + 导出)。
|
||
*/
|
||
class JstOrderLabelsPageTest extends FeatureTestCase
|
||
{
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
|
||
config(['database.connections.business_mirror' => [
|
||
'driver' => 'sqlite',
|
||
'database' => ':memory:',
|
||
'prefix' => '',
|
||
]]);
|
||
DB::purge('business_mirror');
|
||
|
||
Schema::connection('business_mirror')->create('ops_jst_orders', function ($table): void {
|
||
$table->integer('Id')->primary();
|
||
$table->bigInteger('o_id')->nullable();
|
||
$table->string('so_id')->nullable();
|
||
$table->string('shop_name')->nullable();
|
||
$table->dateTime('order_date')->nullable();
|
||
$table->text('labels')->nullable();
|
||
});
|
||
|
||
DB::connection('business_mirror')->table('ops_jst_orders')->insert([
|
||
['Id' => 1, 'o_id' => 101, 'so_id' => '260901-391328633273048', 'shop_name' => '拼多多-优乐猫家具旗舰店', 'order_date' => '2026-09-01 00:00:13', 'labels' => '发货前售后,店铺-拼多多'],
|
||
['Id' => 2, 'o_id' => 102, 'so_id' => '3608400015164657', 'shop_name' => '京喜-久伴吾家具京喜自营工厂店', 'order_date' => '2026-09-01 00:00:44', 'labels' => '京东供销订单, 京喜'],
|
||
['Id' => 3, 'o_id' => 103, 'so_id' => '110211297173802010', 'shop_name' => '得物-全米', 'order_date' => '2026-08-31 23:59:00', 'labels' => '品牌直发'],
|
||
['Id' => 4, 'o_id' => 104, 'so_id' => '260915-000000000000001', 'shop_name' => '得物-全米', 'order_date' => '2026-09-15 10:00:00', 'labels' => ''],
|
||
]);
|
||
|
||
// 商品明细(货号):o_id=101 是换商品后的 X00006 虚拟条码单,102 是普通单
|
||
Schema::connection('business_mirror')->create('ops_jst_order_item', function ($table): void {
|
||
$table->integer('Id')->primary();
|
||
$table->bigInteger('o_id')->nullable();
|
||
$table->string('sku_id')->nullable();
|
||
$table->string('item_status')->nullable();
|
||
});
|
||
DB::connection('business_mirror')->table('ops_jst_order_item')->insert([
|
||
['Id' => 1, 'o_id' => 101, 'sku_id' => 'OLD-SKU', 'item_status' => 'Replaced'],
|
||
['Id' => 2, 'o_id' => 101, 'sku_id' => 'X00006', 'item_status' => null],
|
||
['Id' => 3, 'o_id' => 102, 'sku_id' => 'JD-001', 'item_status' => null],
|
||
['Id' => 4, 'o_id' => 103, 'sku_id' => 'DW-9', 'item_status' => null],
|
||
]);
|
||
|
||
// 全渠道镜像(天猫等):sync-item-codes 命令的回填来源之一
|
||
Schema::connection('business_mirror')->create('ops_jst_qmcust_orders', function ($table): void {
|
||
$table->integer('Id')->primary();
|
||
$table->bigInteger('o_id')->nullable();
|
||
$table->string('so_id')->nullable();
|
||
$table->dateTime('order_date')->nullable();
|
||
});
|
||
Schema::connection('business_mirror')->create('ops_jst_qmcust_order_items', function ($table): void {
|
||
$table->integer('Id')->primary();
|
||
$table->bigInteger('o_id')->nullable();
|
||
$table->string('sku_id')->nullable();
|
||
$table->string('item_status')->nullable();
|
||
});
|
||
DB::connection('business_mirror')->table('ops_jst_qmcust_orders')->insert([
|
||
['Id' => 1, 'o_id' => 201, 'so_id' => '3308965970412095568', 'order_date' => '2026-09-02 10:00:00'],
|
||
]);
|
||
DB::connection('business_mirror')->table('ops_jst_qmcust_order_items')->insert([
|
||
['Id' => 1, 'o_id' => 201, 'sku_id' => 'azf', 'item_status' => 'Replaced'],
|
||
['Id' => 2, 'o_id' => 201, 'sku_id' => 'X00006', 'item_status' => 'Sent'],
|
||
]);
|
||
}
|
||
|
||
public function test_import_splits_labels_without_deduping_orders(): void
|
||
{
|
||
$this->allowPage();
|
||
|
||
$result = app(JstOrderLabelImporter::class)->import('2026-09-01', '2026-09-15', $this->company->id, true);
|
||
|
||
$this->assertSame(3, $result['orders']);
|
||
$this->assertSame(5, $result['rows']);
|
||
$this->assertSame(1, $result['no_label_orders']);
|
||
|
||
$rows = JstOrderLabel::query()->where('company_id', $this->company->id)->orderBy('id')->get();
|
||
$this->assertSame(['发货前售后', '店铺-拼多多', '京东供销订单', '京喜', '(无标签)'], $rows->pluck('label')->all());
|
||
// 同一订单拆成多行,订单不去重
|
||
$this->assertSame(2, $rows->where('order_no', '260901-391328633273048')->count());
|
||
$this->assertSame('2026-09-15 10:00:00', $rows->last()->ordered_at->format('Y-m-d H:i:s'));
|
||
// 货号随导入写入:被替换掉的旧编码不计入
|
||
$this->assertSame('X00006', $rows->firstWhere('label', '发货前售后')->item_code);
|
||
}
|
||
|
||
public function test_import_window_excludes_out_of_range_orders(): void
|
||
{
|
||
$this->allowPage();
|
||
|
||
// 8-31 的订单不在 9 月窗口内
|
||
$result = app(JstOrderLabelImporter::class)->import('2026-09-01', '2026-09-15', $this->company->id, true);
|
||
$orders = JstOrderLabel::query()->where('company_id', $this->company->id)->pluck('order_no')->unique()->all();
|
||
|
||
$this->assertNotContains('110211297173802010', $orders);
|
||
$this->assertSame(3, $result['orders']);
|
||
}
|
||
|
||
public function test_page_filters_and_export(): void
|
||
{
|
||
$this->allowPage();
|
||
app(JstOrderLabelImporter::class)->import('2026-09-01', '2026-09-15', $this->company->id, true);
|
||
|
||
$page = $this->loginAs()->get(route('business.pages.show', [
|
||
'section' => 'planting-data',
|
||
'page' => 'jst-order-labels',
|
||
]));
|
||
|
||
$page->assertOk()
|
||
->assertSee('聚水潭订单标签明细')
|
||
->assertSee('260901-391328633273048')
|
||
->assertSee('店铺-拼多多')
|
||
->assertSee('(无标签)')
|
||
->assertSee('货号')
|
||
->assertSee('X00006')
|
||
->assertSee('id="jst-label-date-from"', false)
|
||
->assertSee('data-jst-labels-clear="jst-label-date-to"', false);
|
||
|
||
$filtered = $this->loginAs()->get(route('business.pages.show', [
|
||
'section' => 'planting-data',
|
||
'page' => 'jst-order-labels',
|
||
'label' => '京喜',
|
||
]));
|
||
$filtered->assertOk()
|
||
->assertSee('3608400015164657')
|
||
->assertDontSee('260901-391328633273048');
|
||
|
||
$export = $this->loginAs()->get(route('business.planting-data.jst-order-labels.export', [
|
||
'label' => '京喜',
|
||
]));
|
||
$export->assertOk()->assertDownload();
|
||
$csv = $export->streamedContent();
|
||
$this->assertStringContainsString('线上订单号,店铺名称,下单时间,是否种菜订单,标签,货号', explode("\n", $csv)[0]);
|
||
$this->assertStringContainsString('3608400015164657', $csv);
|
||
$this->assertStringContainsString('JD-001', $csv);
|
||
$this->assertStringNotContainsString('260901-391328633273048', $csv);
|
||
}
|
||
|
||
public function test_item_code_marks_planting_orders_and_exports(): void
|
||
{
|
||
$this->allowPage();
|
||
app(JstOrderLabelImporter::class)->import('2026-09-01', '2026-09-15', $this->company->id, true);
|
||
|
||
$rules = app(\App\Business\JstOrderLabelPlantingRules::class);
|
||
// 货号 X00006(信誉订单虚拟条码)直接判为种菜订单,与标签映射无关
|
||
$this->assertSame('是', $rules->flag($this->company->id, '发货前售后', 'X00006'));
|
||
$this->assertSame('否', $rules->flag($this->company->id, '发货前售后', 'JD-001'));
|
||
$this->assertSame('是', $rules->flag($this->company->id, '发货前售后', 'JD-001,X00006'));
|
||
|
||
// 导出里能看到货号列和「是否种菜订单=是」
|
||
$export = $this->loginAs()->get(route('business.planting-data.jst-order-labels.export', []));
|
||
$export->assertOk()->assertDownload();
|
||
$csv = $export->streamedContent();
|
||
$this->assertStringContainsString('是,发货前售后,X00006', $csv);
|
||
$this->assertStringContainsString('否,京喜,JD-001', $csv);
|
||
}
|
||
|
||
public function test_sync_item_codes_command_backfills_existing_rows(): void
|
||
{
|
||
$this->allowPage();
|
||
app(JstOrderLabelImporter::class)->import('2026-09-01', '2026-09-15', $this->company->id, true);
|
||
|
||
// 模拟历史数据:清空货号,并补一条全渠道镜像来源的标签行
|
||
JstOrderLabel::query()->where('company_id', $this->company->id)->update(['item_code' => null]);
|
||
JstOrderLabel::create([
|
||
'company_id' => $this->company->id,
|
||
'order_no' => '3308965970412095568',
|
||
'shop_name' => '天猫-亿家达旗舰店',
|
||
'ordered_at' => '2026-09-02 10:00:00',
|
||
'label' => '刷单待处理',
|
||
'created_from' => 'jst-order-label-qmcust-import',
|
||
]);
|
||
|
||
$this->artisan('jst-order-labels:sync-item-codes', ['--company' => $this->company->id])
|
||
->assertSuccessful();
|
||
|
||
$rows = JstOrderLabel::query()->where('company_id', $this->company->id)->get();
|
||
$this->assertSame('X00006', $rows->firstWhere('label', '发货前售后')->item_code);
|
||
$this->assertSame('JD-001', $rows->firstWhere('label', '京喜')->item_code);
|
||
$this->assertSame('X00006', $rows->firstWhere('order_no', '3308965970412095568')->item_code);
|
||
}
|
||
|
||
private function allowPage(): void
|
||
{
|
||
$this->user->attachPermission(
|
||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||
);
|
||
}
|
||
|
||
public function test_import_from_erp_export_file_dedupes_lines_and_labels(): void
|
||
{
|
||
$this->allowPage();
|
||
|
||
$path = $this->makeExportFile([
|
||
['内部订单号', '线上订单号', '店铺名称', '下单时间', '标签'],
|
||
['6476896', '3424493003753167', '京东-柯诺苏家具旗舰店', '2026-03-01 01:30:52', '发货后售后,店铺-京东,晚发赔'],
|
||
['6476897', '3424493003753167', '京东-柯诺苏家具旗舰店', '2026-03-01 01:30:52', '发货后售后,店铺-京东,晚发赔'],
|
||
['6476898', '3424493003753167', '京东-柯诺苏家具旗舰店', '2026-03-01 01:30:52', '发货后售后'],
|
||
// 同一单里只有部分明细行带标签:不能因此多写一行「(无标签)」
|
||
['6476899', '3424493003753167', '京东-柯诺苏家具旗舰店', '2026-03-01 01:30:52', ''],
|
||
['6477350', '110197275064171099', '得物-米客米家家居旗舰店', '2026-03-01 08:05:36', ''],
|
||
['9535498', '3290154852795260451', '淘宝-亿家达家居鞋柜电脑桌', '2026-03-11 19:01:30', '历史订单'],
|
||
]);
|
||
|
||
try {
|
||
$result = app(JstOrderLabelImporter::class)->importFromFile($path, null, null, $this->company->id, false);
|
||
} finally {
|
||
@unlink($path);
|
||
}
|
||
|
||
// 同一订单的多条明细行只算一单,重复标签只写一行,
|
||
// 且「部分明细行没标签」不会污染成「(无标签)」
|
||
$this->assertSame(3, $result['orders']);
|
||
$this->assertSame(5, $result['rows']);
|
||
$this->assertSame(1, $result['no_label_orders']);
|
||
|
||
$this->assertSame(0, JstOrderLabel::query()->where('company_id', $this->company->id)
|
||
->where('order_no', '3424493003753167')
|
||
->where('label', JstOrderLabelImporter::NO_LABEL)->count());
|
||
|
||
$labels = JstOrderLabel::query()->where('company_id', $this->company->id)
|
||
->where('order_no', '3424493003753167')->pluck('label')->sort()->values()->all();
|
||
$this->assertSame(['发货后售后', '店铺-京东', '晚发赔'], $labels);
|
||
|
||
$noLabel = JstOrderLabel::query()->where('company_id', $this->company->id)
|
||
->where('order_no', '110197275064171099')->firstOrFail();
|
||
$this->assertSame(JstOrderLabelImporter::NO_LABEL, $noLabel->label);
|
||
$this->assertSame('得物-米客米家家居旗舰店', $noLabel->shop_name);
|
||
}
|
||
|
||
public function test_import_from_file_respects_window_and_replace(): void
|
||
{
|
||
$this->allowPage();
|
||
app(JstOrderLabelImporter::class)->import('2026-09-01', '2026-09-15', $this->company->id, true);
|
||
|
||
$path = $this->makeExportFile([
|
||
['线上订单号', '店铺名称', '下单时间', '标签'],
|
||
['A-1', '店铺甲', '2026-03-05 10:00:00', '刷单待处理'],
|
||
['A-2', '店铺甲', '2026-03-06 10:00:00', '刷单待处理'],
|
||
]);
|
||
|
||
try {
|
||
$result = app(JstOrderLabelImporter::class)->importFromFile(
|
||
$path, '2026-03-06', '2026-03-31', $this->company->id, true, 'test'
|
||
);
|
||
} finally {
|
||
@unlink($path);
|
||
}
|
||
|
||
// 窗口外的 3-05 被跳过;9 月的老数据在窗口外因此不受 --replace 影响
|
||
$this->assertSame(1, $result['orders']);
|
||
$this->assertSame(1, $result['skipped']);
|
||
$this->assertSame(['A-2'], JstOrderLabel::query()->where('company_id', $this->company->id)
|
||
->where('order_no', 'like', 'A-%')->pluck('order_no')->all());
|
||
// 原有 9 月 5 行 + 新增 A-2 一行
|
||
$this->assertSame(6, JstOrderLabel::query()->where('company_id', $this->company->id)->count());
|
||
}
|
||
|
||
/**
|
||
* 按聚水潭「导出订单」的真实形态造一个最小 xlsx:
|
||
* 字符串走 inlineStr、dimension 只写 A1(尺寸不可信)、一单多行。
|
||
*
|
||
* @param list<list<string>> $rows
|
||
*/
|
||
private function makeExportFile(array $rows): string
|
||
{
|
||
$path = tempnam(sys_get_temp_dir(), 'jst-export-') . '.xlsx';
|
||
$sheet = '<?xml version="1.0" encoding="UTF-8"?>'
|
||
. '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||
. '<dimension ref="A1"/><sheetData>';
|
||
|
||
foreach ($rows as $rowIndex => $values) {
|
||
$sheet .= '<row r="' . ($rowIndex + 1) . '">';
|
||
foreach ($values as $columnIndex => $value) {
|
||
$reference = Coordinate::stringFromColumnIndex($columnIndex + 1) . ($rowIndex + 1);
|
||
$sheet .= '<c r="' . $reference . '" t="inlineStr"><is><t>'
|
||
. htmlspecialchars((string) $value, ENT_XML1) . '</t></is></c>';
|
||
}
|
||
$sheet .= '</row>';
|
||
}
|
||
$sheet .= '</sheetData></worksheet>';
|
||
|
||
$zip = new \ZipArchive();
|
||
$this->assertTrue($zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) === true);
|
||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0" encoding="UTF-8"?>'
|
||
. '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||
. '<Default Extension="xml" ContentType="application/xml"/>'
|
||
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||
. '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
||
. '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||
. '</Types>');
|
||
$zip->addFromString('_rels/.rels', '<?xml version="1.0" encoding="UTF-8"?>'
|
||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
||
. '</Relationships>');
|
||
$zip->addFromString('xl/workbook.xml', '<?xml version="1.0" encoding="UTF-8"?>'
|
||
. '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
||
. 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||
. '<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>');
|
||
$zip->addFromString('xl/_rels/workbook.xml.rels', '<?xml version="1.0" encoding="UTF-8"?>'
|
||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
|
||
. '</Relationships>');
|
||
$zip->addFromString('xl/sharedStrings.xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>'
|
||
. '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="0" uniqueCount="0"></sst>');
|
||
$zip->addFromString('xl/worksheets/sheet1.xml', "\xEF\xBB\xBF" . $sheet);
|
||
$zip->close();
|
||
|
||
return $path;
|
||
}
|
||
}
|