diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index f94cce19..9604e4ef 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -1732,6 +1732,29 @@ class Pages extends Controller return; } + if ($platform === 'jd') { + fputcsv($csv, [ + '平台', '店铺', '账户', '日期', '收支类型', '交易类型', + '金额(元)', '余额(元)', '备注', + ]); + foreach ($data['jd'] ?? [] as $row) { + fputcsv($csv, [ + '京东京准通', + $row['shop_name'] ?? '', + $row['account'] ?? '', + $row['show_date'] ?? $row['settle_datetime'] ?? '', + $row['in_out_type_name'] ?? '', + $row['trade_type_name'] ?? '', + $row['amount'] ?? '', + $row['balance_amount'] ?? '', + $row['remark'] ?? '', + ]); + } + fclose($csv); + + return; + } + fputcsv($csv, ['平台', '店铺/账户', '日期', '类型', '金额(元)', '余额(元)', '备注']); $emit = static function (string $platform, array $rows, array $map) use ($csv): void { foreach ($rows as $row) { @@ -1746,9 +1769,7 @@ class Pages extends Controller ]); } }; - if ($platform === 'jd') { - $emit('京东京准通', $data['jd'] ?? [], ['name' => fn ($row) => $row['shop_name'] . ' / ' . $row['account'], 'date' => fn ($row) => $row['show_date'] ?? $row['settle_datetime'], 'type' => fn ($row) => $row['trade_type_name'] ?? $row['in_out_type_name'], 'amount' => fn ($row) => $row['amount'] ?? '', 'balance' => fn ($row) => $row['balance_amount'] ?? '', 'remark' => fn ($row) => $row['remark'] ?? '']); - } elseif ($platform === 'douyin') { + if ($platform === 'douyin') { $emit('抖音千川', $data['dy'] ?? [], ['name' => fn ($row) => $row['shopName'] . ' / ' . $row['account'] . ' / ' . $row['qianchuanAavid'], 'date' => fn ($row) => $row['statDate'] ?? '', 'type' => fn () => '花费', 'amount' => fn ($row) => $row['cost'] ?? '', 'balance' => fn ($row) => $row['balance'] ?? '', 'remark' => fn ($row) => $row['remark'] ?? '']); } elseif ($platform === 'dewu') { $emit('得物推广', $data['dw'] ?? [], ['name' => fn ($row) => $row['shop_name'] . ' / ' . $row['account'], 'date' => fn ($row) => $row['trade_date'] ?? $row['date_time'], 'type' => fn ($row) => $row['flow_type'] ?? '', 'amount' => fn ($row) => $row['trade_amount_yuan'] ?? '', 'balance' => fn ($row) => $row['balance_yuan'] ?? '', 'remark' => fn ($row) => $row['remark'] ?? '']); diff --git a/app/Listeners/Menu/ShowInAdmin.php b/app/Listeners/Menu/ShowInAdmin.php index d2c94b1d..b1d0f791 100644 --- a/app/Listeners/Menu/ShowInAdmin.php +++ b/app/Listeners/Menu/ShowInAdmin.php @@ -20,7 +20,34 @@ class ShowInAdmin */ public function handle(Event $event): void { - $this->renderSections($event->menu, self::groupPayables(SidebarPermissions::navigationFor(user()))); + $this->renderSections($event->menu, self::groupBasicData(self::groupPayables(SidebarPermissions::navigationFor(user())))); + } + + public static function groupBasicData(array $navigation): array + { + $keys = ['order-data', 'refunds']; + $children = array_values(array_filter($navigation, static fn (array $section): bool => in_array($section['slug'], $keys, true))); + if ($children === []) { + return $navigation; + } + + $group = ['slug' => 'basic-data', 'title' => '基础数据', 'icon' => 'folder', 'sections' => $children]; + $hasDirectory = in_array('basic-data', array_column($navigation, 'slug'), true); + $result = []; + foreach ($navigation as $section) { + if ($section['slug'] === 'basic-data') { + $result[] = array_replace($section, ['sections' => $children]); + } elseif (in_array($section['slug'], $keys, true)) { + if (! $hasDirectory) { + $result[] = $group; + $hasDirectory = true; + } + } else { + $result[] = $section; + } + } + + return $result; } public static function groupPayables(array $navigation): array diff --git a/config/business_navigation.php b/config/business_navigation.php index e7a6e11e..e9f1b1f8 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -289,9 +289,15 @@ $navigation = [ ], ], ], + [ + 'slug' => 'basic-data', + 'title' => '基础数据', + 'icon' => 'folder', + 'pages' => [], + ], [ 'slug' => 'product-inventory', - 'title' => '商品及库存管理', + 'title' => '商品价格管理', 'icon' => 'inventory_2', 'pages' => [ ['slug' => 'normal-products', 'title' => '普通商品资料'], @@ -654,6 +660,7 @@ $navigation = [ // Keep the requested finance-data sections together while preserving every other section's order. $requestedSectionOrder = [ + 'basic-data', 'invoice-management', 'fund-management', 'order-data', diff --git a/resources/assets/js/components/business/AccountBills.vue b/resources/assets/js/components/business/AccountBills.vue index 48365b53..8b1481b6 100644 --- a/resources/assets/js/components/business/AccountBills.vue +++ b/resources/assets/js/components/business/AccountBills.vue @@ -18,7 +18,7 @@ - +
发生时间
至
@@ -71,6 +71,7 @@ export default { async fetch(){const requestId=++this.requestSerial;this.loading=true;this.loadError=false;try{const response=await window.axios.get(this.apiBase,{params:{...this.filters,sort:this.sort.key,dir:this.sort.dir,limit:this.perPage,page:this.currentPage}});if(requestId!==this.requestSerial)return;const body=response.data||{};this.rows=body.data||[];this.total=body.total||0;this.currentPage=body.current_page||1;this.lastPage=body.last_page||1;this.perPage=body.per_page||this.perPage;this.summary=body.summary||{income:0,expense:0,balance:0,count:0};if(body.columns){this.columnsTitle=body.columns.title||'';this.sourceGroups=body.columns.groups||[];this.sourceColumns=this.attachGroups(body.columns.columns||body.columns||[],this.sourceGroups);}}catch(error){if(requestId!==this.requestSerial)return;this.rows=[];this.total=0;this.summary={income:0,expense:0,balance:0,count:0};this.loadError=true;}finally{if(requestId===this.requestSerial)this.loading=false;}}, attachGroups(columns,groups){let index=0;return columns.map((column,columnIndex)=>{while(groups[index]&&columnIndex>=groups.slice(0,index+1).reduce((sum,group)=>sum+Number(group.span||0),0))index+=1;return{...column,key:column.field||`column-${columnIndex}`,group:groups[index]?groups[index].label:''};});}, applyFilters(){if(this.invalidDateRange)return;this.currentPage=1;this.fetch();},resetFilters(){this.filters={store_name:'',bill_item:'',start:'',end:'',keyword:''};this.applyFilters();},changePage(page){this.currentPage=page;this.fetch();this.$nextTick(()=>{if(this.$refs.scroll)this.$refs.scroll.scrollTop=0;});}, + openDatePicker(refName){const input=this.$refs[refName];if(!input)return;input.focus();if(typeof input.showPicker==='function'){try{input.showPicker();return;}catch(error){/* Fall through for embedded browsers without picker support. */}}input.click();}, maybeSort(column){if(!this.sortableField(column.field))return;this.sort.dir=this.sort.key===column.field&&this.sort.dir==='desc'?'asc':'desc';this.sort.key=column.field;this.applyFilters();},sortableField(field){return SORTABLE.indexOf(field)!==-1;},sortMark(field){return this.sort.key===field?(this.sort.dir==='asc'?'↑':'↓'):'↕';}, getValue(row,field){if(!field)return'';if(field.indexOf('payload.')===0){const payload=row.payload||{};const value=payload[field.slice(8)];return value===null||value===undefined?'':value;}const value=row[field];return value===null||value===undefined?'':value;},rawValue(row,column){return this.getValue(row,column.field);}, displayValue(row,column){const value=this.rawValue(row,column);if(value==='')return'—';if(column.kind==='money_in')return Number(value)>0?`+${this.money(value)}`:'—';if(column.kind==='money_out')return Number(value)>0?`-${this.money(value)}`:'—';if(['balance','money'].indexOf(column.kind)!==-1)return this.money(value);return value;},cellClass(column){return{'is-number':this.isNumber(column.kind),'is-id':column.kind==='num','is-income':column.kind==='money_in','is-expense':column.kind==='money_out'};},isNumber(kind){return['money','money_in','money_out','balance','num'].indexOf(kind)!==-1;},money(value){return`¥${Number(value||0).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})}`;}, @@ -81,8 +82,8 @@ export default { diff --git a/resources/views/banking/marketing_platform_promotion_bills/partials/embedded-table.blade.php b/resources/views/banking/marketing_platform_promotion_bills/partials/embedded-table.blade.php index e1552b9e..2fade9a7 100644 --- a/resources/views/banking/marketing_platform_promotion_bills/partials/embedded-table.blade.php +++ b/resources/views/banking/marketing_platform_promotion_bills/partials/embedded-table.blade.php @@ -11,7 +11,7 @@ $titles = ['jd' => '京东京准通', 'douyin' => '抖音千川', 'dewu' => '得物推广账户', 'vip' => '唯品会推广流水']; $filterLabel = $platform === 'vip' ? $titles[$platform] . '筛选' : $titles[$platform] . '推广账户筛选'; $columns = match ($platform) { - 'jd' => [['shop_name', '店铺'], ['account', '账户'], ['show_date', '日期'], ['trade_type_name', '交易类型'], ['amount', '金额'], ['balance_amount', '余额'], ['remark', '备注']], + 'jd' => [['shop_name', '店铺'], ['account', '账户'], ['show_date', '日期'], ['in_out_type_name', '收支类型'], ['trade_type_name', '交易类型'], ['amount', '金额'], ['balance_amount', '余额'], ['remark', '备注']], 'douyin' => [['shopName', '店铺'], ['account', '账户'], ['qianchuanAavid', '广告账户'], ['statDate', '日期'], ['cost', '花费'], ['income', '收入'], ['transferIn', '转入'], ['transferOut', '转出'], ['balance', '余额'], ['status', '状态'], ['remark', '备注']], 'dewu' => [['shop_name', '店铺'], ['account', '账户'], ['trade_date', '日期'], ['amount_type', '金额类型'], ['flow_type', '收支类型'], ['trade_amount_yuan', '金额'], ['balance_yuan', '余额'], ['remark', '备注']], default => [ diff --git a/tests/Feature/Business/MarketingPlatformPromotionBillsTest.php b/tests/Feature/Business/MarketingPlatformPromotionBillsTest.php index 73f88d68..96a483cc 100644 --- a/tests/Feature/Business/MarketingPlatformPromotionBillsTest.php +++ b/tests/Feature/Business/MarketingPlatformPromotionBillsTest.php @@ -32,6 +32,8 @@ class MarketingPlatformPromotionBillsTest extends FeatureTestCase ->assertSeeText('京东京准通') ->assertSee('aria-label="京东京准通推广账户筛选"', false) ->assertSeeText('测试店铺') + ->assertSeeText('收支类型') + ->assertSeeText('出账') ->assertSeeText('导出 CSV') ->assertSee('class="my-6 business-page--marketing-account-bills"', false) ->assertSee(route('business.pages.show', [ @@ -72,7 +74,12 @@ class MarketingPlatformPromotionBillsTest extends FeatureTestCase "filename*=utf-8''" . rawurlencode($filename), (string) $response->headers->get('Content-Disposition'), ); - $this->assertStringContainsString('测试店铺', $response->streamedContent()); + $csv = $response->streamedContent(); + $this->assertStringContainsString('测试店铺', $csv); + $this->assertStringContainsString('收支类型', $csv); + $this->assertStringContainsString('交易类型', $csv); + $this->assertStringContainsString('出账', $csv); + $this->assertStringContainsString('推广费', $csv); } public function test_vip_tab_uses_marketing_daily_data(): void @@ -195,7 +202,8 @@ class MarketingPlatformPromotionBillsTest extends FeatureTestCase 'shop_name' => '测试店铺', 'account' => 'account-01', 'show_date' => '2026-08-01', - 'trade_type_name' => '充值', + 'in_out_type_name' => '出账', + 'trade_type_name' => '推广费', 'amount' => 100, 'balance_amount' => 100, 'remark' => '测试记录', diff --git a/tests/Feature/Business/PagesTest.php b/tests/Feature/Business/PagesTest.php index 4c675e55..18d9cefd 100644 --- a/tests/Feature/Business/PagesTest.php +++ b/tests/Feature/Business/PagesTest.php @@ -217,14 +217,17 @@ class PagesTest extends FeatureTestCase ]); } - public function testDouyinAccountBillsExportUsesSelectedColumnOrder(): void + public function testDouyinAccountBillsExportUsesDateFiltersAndSelectedColumnOrder(): void { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $repository = Mockery::mock(StoreReceivingDouyinBillMirrorRepository::class); - $repository->shouldReceive('exportRows')->once()->with([], 'occurred_at', 'desc')->andReturn([[ + $repository->shouldReceive('exportRows')->once()->with([ + 'start' => '2026-09-09', + 'end' => '2026-09-09', + ], 'occurred_at', 'desc')->andReturn([[ 'record_code' => '9001', 'payload' => [ 'move_scene' => '头条放心购', @@ -235,6 +238,8 @@ class PagesTest extends FeatureTestCase $response = $this->loginAs()->get(route('ecommerce-finance.account-bills.export', [ 'bill_type' => 'douyin', + 'start' => '2026-09-09', + 'end' => '2026-09-09', 'columns' => 'payload.move_scene,record_code,payload.settle_account', ])); diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php index 853558cc..8fb57a89 100644 --- a/tests/Feature/Settings/NavigationTest.php +++ b/tests/Feature/Settings/NavigationTest.php @@ -9,6 +9,40 @@ use Tests\Feature\FeatureTestCase; class NavigationTest extends FeatureTestCase { + public function testGroupedNavigationRendersExistingPageLinks(): void + { + $this->loginAs() + ->get(route('business.pages.show', ['section' => 'basic-data'])) + ->assertOk() + ->assertSeeText('基础数据') + ->assertSeeText('商品价格管理') + ->assertSee('/workspace/order-data/raw-data', false) + ->assertSee('/workspace/refunds/non-jingxi-refund-data', false) + ->assertSee('/workspace/product-inventory/normal-products', false); + } + + public function testGroupedNavigationKeepsRestrictedPagePermissions(): void + { + $this->user->roles()->sync([Role::where('name', 'manager')->firstOrFail()->id]); + $subject = 'user_' . $this->user->id; + setting()->set('system.sidebar_permissions', [ + 'configured' => [$subject], + 'subjects' => [$subject => ['basic-data', 'refunds/non-jingxi-refund-data']], + ]); + setting()->save(); + + $this->loginAs() + ->get(route('business.pages.show', ['section' => 'basic-data'])) + ->assertOk() + ->assertSee('/workspace/refunds/non-jingxi-refund-data', false) + ->assertDontSee('/workspace/order-data/raw-data', false) + ->assertDontSee('/workspace/product-inventory/normal-products', false); + + $this->withExceptionHandling() + ->get(route('business.pages.show', ['section' => 'order-data'])) + ->assertForbidden(); + } + public function testAdminCanUpdateSidebarLabels() { $this->user->roles()->sync([ @@ -87,7 +121,7 @@ class NavigationTest extends FeatureTestCase '退款数据', '账单流水数据', '账单余额数据', - '商品及库存管理', + '商品价格管理', ], array_column(array_slice($navigation, $invoiceIndex, 7), 'title') ); diff --git a/tests/Unit/EcommerceFinance/PlatformPromotionAccountRepositoryTest.php b/tests/Unit/EcommerceFinance/PlatformPromotionAccountRepositoryTest.php index 095458b1..39f96dce 100644 --- a/tests/Unit/EcommerceFinance/PlatformPromotionAccountRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/PlatformPromotionAccountRepositoryTest.php @@ -21,7 +21,8 @@ class PlatformPromotionAccountRepositoryTest extends TestCase $factory->shouldReceive('forSource')->once()->with('js_data_middle_platform')->andReturn($query); $query->shouldReceive('select')->once()->ordered()->with( Mockery::on(static fn (string $sql): bool => str_contains($sql, 'SELECT TOP 5000') - && str_contains($sql, 'jd_jzt_finance_records')), + && str_contains($sql, 'jd_jzt_finance_records') + && str_contains($sql, 'in_out_type_name')), ['2026-08'], )->andReturn([['shop_name' => '测试店铺', 'amount' => 100]]); $query->shouldReceive('select')->once()->ordered()->with( diff --git a/tests/Unit/View/BasicDataNavigationTest.php b/tests/Unit/View/BasicDataNavigationTest.php new file mode 100644 index 00000000..162b25db --- /dev/null +++ b/tests/Unit/View/BasicDataNavigationTest.php @@ -0,0 +1,46 @@ +assertSame(['order-data', 'refunds'], array_column($group['sections'], 'slug')); + $this->assertNotContains('order-data', $slugs); + $this->assertNotContains('refunds', $slugs); + foreach ($group['sections'] as $section) { + $this->assertSame($navigation[array_search($section['slug'], array_column($navigation, 'slug'), true)], $section); + } + } + + public function test_group_contains_only_authorized_sections_even_without_directory_permission(): void + { + $allowed = [['slug' => 'refunds', 'title' => '退款', 'pages' => [['slug' => 'non-jingxi-refund-data']]]]; + $grouped = ShowInAdmin::groupBasicData($allowed); + $this->assertSame($allowed, $grouped[0]['sections']); + $this->assertSame([], ShowInAdmin::groupBasicData([])); + } + + public function test_basic_data_is_an_empty_top_level_directory_before_invoice_management(): void + { + $navigation = require dirname(__DIR__, 3) . '/config/business_navigation.php'; + $slugs = array_column($navigation, 'slug'); + $index = array_search('basic-data', $slugs, true); + + $this->assertNotFalse($index); + $this->assertSame('基础数据', $navigation[$index]['title']); + $this->assertSame([], $navigation[$index]['pages']); + $this->assertSame('invoice-management', $slugs[$index + 1]); + $this->assertCount(1, array_keys($slugs, 'basic-data', true)); + } +}