> 开工时标题使用 `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
388 lines
19 KiB
PHP
388 lines
19 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();
|
||
$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;
|
||
}
|
||
}
|