CaiWuXiTong/tests/Feature/Business/JstOrderLabelsPageTest.php
qiuxiang e00d7cf871 feature/qiuxiang-jst-ssdd-seller-remark (#725)
> 开工时标题使用 `WIP: <任务名称>` 并先填写“任务占用与边界”。Codex 完成独立合并前自审后补齐其余内容并移除 `WIP:`。

## 任务占用与边界

- 任务名称:
- 实际开发者:
- 任务来源/团队分工:
- 目标结果:
- 预计修改路径:
- 将写入的公共契约(路由、Controller 方法、Repository、表/视图、`source_key`、`scope`、权限名、菜单键或共享组件):
- 仅复用、明确不修改的公共契约:
- 数据源、表/视图和字段:
- 上游 `source_key` / `scope`:
- 权限名、菜单键或共享组件:
- 明确不修改的范围:

## 开工碰撞检查

- [ ] Codex 已执行 `scripts/codex-task-preflight.ps1`
- [ ] 已检查最新 `main` 和所有远程任务分支
- [ ] 已搜索主线既有页面、接口、Repository 和数据查询
- [ ] 已把“将写入”和“仅复用、不修改”的公共契约分开;仅复用项不作为单独阻断条件
- [ ] 未发现实际写入重叠;或已在下方写明相关分支、具体重叠项和协调结论

相关分支、重叠项与协调结论:

## 实际改动

- 完成内容:
- Codex 实际修改的文件:
- 新增或变更的公共契约:
- 与原计划的差异:

## 验证证据

- 正常流程:
- 空数据/错误输入/无权限场景:
- 受影响旧功能:
- 自动测试及结果:
- 实际页面、接口或只读业务查询结果:

## 风险检查

- [ ] 不涉及金额、权限、审批、数据库迁移、生产配置或数据修复
- [ ] 如涉及上述高风险内容,已在下方写明影响、回滚和所需授权
- [ ] 未提交 `.env`、密码、Token、私钥、真实财务数据、生产日志或构建产物
- [ ] 前端改动符合 `DESIGN.md`;无前端改动则写“不适用”

高风险影响、回滚和授权状态:

## 合并前复查

- [ ] 已重新拉取远程引用并用实际修改范围再次运行碰撞检查
- [ ] PR 不再是 `WIP:`,所有测试结果已经补齐
- [ ] Codex 已重新读取原始需求和验收标准,并逐文件审核 `origin/main...任务分支` 的完整 diff
- [ ] Codex 自审确认无越界修改、重复实现、敏感内容、未处理冲突和未验证结果
- [ ] 合并顺序不会覆盖其他未合并任务
- [ ] 已授权当前任务的 Codex 执行 Squash merge、验证 `main` 并清理任务分支
- [ ] 如涉及生产迁移、数据修复、删除、覆盖配置或部署,已另行取得对应高风险授权

Reviewed-on: #725
2026-10-28 13:48:37 +08:00

388 lines
19 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 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();
$table->text('remark')->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_import_uses_ssdd_seller_remark_as_a_planting_label(): void
{
$this->allowPage();
DB::connection('business_mirror')->table('ops_jst_orders')->insert([
'Id' => 5,
'o_id' => 105,
'so_id' => '260915-SSDD-REMARK',
'shop_name' => '拼多多-索菲亚书房官方旗舰店',
'order_date' => '2026-09-15 11:00:00',
'labels' => '',
'remark' => 'SSDD。特殊单',
]);
DB::table('jst_order_label_planting_rules')->insert([
'company_id' => $this->company->id,
'label' => 'SSDD',
'is_planting' => '是',
'created_at' => now(),
'updated_at' => now(),
]);
app(\App\Business\JstOrderLabelPlantingRules::class)->forget($this->company->id);
app(JstOrderLabelImporter::class)->import('2026-09-01', '2026-09-15', $this->company->id, true);
$labels = JstOrderLabel::query()
->where('company_id', $this->company->id)
->where('order_no', '260915-SSDD-REMARK')
->pluck('label')
->all();
$this->assertSame([JstOrderLabelImporter::SELLER_REMARK_LABEL], $labels);
$this->assertSame(
'是',
app(\App\Business\JstOrderLabelPlantingRules::class)
->flag($this->company->id, JstOrderLabelImporter::SELLER_REMARK_LABEL),
);
}
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_uses_optional_ssdd_seller_remark(): void
{
$this->allowPage();
$path = $this->makeExportFile([
['线上订单号', '店铺名称', '下单时间', '标签', '卖家备注'],
['260915-FILE-SSDD', '拼多多-索菲亚书房官方旗舰店', '2026-09-15 12:00:00', '', 'SSDD。特殊单'],
]);
try {
app(JstOrderLabelImporter::class)->importFromFile($path, null, null, $this->company->id, false);
} finally {
@unlink($path);
}
$this->assertSame(
[JstOrderLabelImporter::SELLER_REMARK_LABEL],
JstOrderLabel::query()
->where('company_id', $this->company->id)
->where('order_no', '260915-FILE-SSDD')
->pluck('label')
->all(),
);
}
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;
}
}