- |
+ |
inbox
{{ $descriptor->readOnly ? '暂无工作平台数据' : '暂无数据,点击右上角"新增"开始录入' }}
@@ -774,6 +1029,65 @@
+ @if ($page['slug'] === 'installation-details')
+
+ @endif
+
+ @if ($page['slug'] === 'installation-details')
+
+ @endif
+
@unless ($descriptor->readOnly)
diff --git a/routes/admin.php b/routes/admin.php
index 63fbc09f..9f24576e 100644
--- a/routes/admin.php
+++ b/routes/admin.php
@@ -268,10 +268,6 @@ Route::get('workspace/product-inventory/monthly-weighted-average-price/export',
->middleware('business.page:financial-operations,performance-details')
->name('business.financial-operations.performance-details.metric-details');
- Route::get('workspace/financial-operations/performance-details/installation-fee-details', 'Business\Pages@installationFeeMetricDetails')
- ->middleware('business.page:financial-operations,performance-details')
- ->name('business.financial-operations.performance-details.installation-fee-details');
-
Route::get('workspace/financial-operations/product-costs/export', 'Business\Pages@exportProductCosts')
->middleware('business.page:financial-operations,product-costs')
->name('business.financial-operations.product-costs.export');
@@ -582,6 +578,16 @@ Route::get('workspace/fund-management/fund-forecast/template', 'Business\Pages@f
->middleware('business.page:fund-management,fund-forecast')
->name('business.fund-forecast.template');
+Route::get('workspace/financial-operations/installation-details/summary', 'Business\Pages@installationDetailsSummary')
+ ->name('business.installation-details.summary');
+
+Route::get('workspace/financial-operations/installation-details/details/{guid}', 'Business\Pages@installationDetails')
+ ->where('guid', '[A-Za-z0-9-]+')
+ ->name('business.installation-details.details');
+
+Route::get('workspace/financial-operations/installation-details/export', 'Business\Pages@exportInstallationDetails')
+ ->name('business.installation-details.export');
+
Route::get('workspace/trademark-management/store-report-data-logic/edit', 'Business\Pages@editStoreReportLogic')
->middleware('business.page:trademark-management,store-report-data-logic')
->name('business.store-report-logic.edit');
diff --git a/tests/Feature/Business/ManagementCrudTest.php b/tests/Feature/Business/ManagementCrudTest.php
index 53883ea3..15b60f69 100644
--- a/tests/Feature/Business/ManagementCrudTest.php
+++ b/tests/Feature/Business/ManagementCrudTest.php
@@ -5,12 +5,14 @@ namespace Tests\Feature\Business;
use App\Business\UpstreamBusinessInformation;
use App\Business\StoreReceivingAccountSynchronizer;
use App\Models\Auth\Permission;
+use App\Models\Business\InstallationDetail;
use App\Models\Business\StoreReceivingAccount;
use App\Utilities\BusinessNavigation;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Mockery;
+use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Tests\Feature\FeatureTestCase;
@@ -220,6 +222,168 @@ class ManagementCrudTest extends FeatureTestCase
->assertSee('value="提现测试主体" selected', false);
}
+ public function testInstallationDetailsRendersWorkbookHeadersAndImportedRows(): void
+ {
+ $mirror = Mockery::mock(InstallationDetailMirrorRepository::class);
+ $mirror->shouldReceive('paginate')->once()->andReturn([
+ 'ready' => true, 'total' => 1, 'data' => [array_replace(array_fill_keys(array_keys(InstallationDetail::columnLabels()), null), [
+ 'id' => 1, 'installation_no' => 'INSTALL-TEST-001', 'installation_company' => '测试安装公司',
+ 'store_name' => '测试店铺', 'requested_amount' => '120.50', 'settled_amount' => '100.00', 'item_quantity' => 2,
+ 'related_guid' => 'guid-install-test',
+ ])],
+ ]);
+ $this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
+
+ $this->loginAs()
+ ->get(route('business.pages.show', [
+ 'section' => 'financial-operations',
+ 'page' => 'installation-details',
+ ]))
+ ->assertOk()
+ ->assertSeeText('安装明细 · 数据列表')
+ ->assertSeeText('安装单号')
+ ->assertSeeText('申请总金额')
+ ->assertSeeText('商品名称(1)')
+ ->assertSeeText('创建日期:')
+ ->assertSeeText('客服审核时间:')
+ ->assertSeeText('财务审核时间:')
+ ->assertSeeText('推送状态:')
+ ->assertSeeText('选择店铺:')
+ ->assertSeeText('选择删除:')
+ ->assertSeeText('订单类型:')
+ ->assertSeeText('商品ID:')
+ ->assertSeeText('导出')
+ ->assertSee('id="installationFilterForm"', false)
+ ->assertSee('name="created_from"', false)
+ ->assertSee('name="created_to"', false)
+ ->assertSee('name="customer_review_from"', false)
+ ->assertSee('name="customer_review_to"', false)
+ ->assertSee('name="finance_review_from"', false)
+ ->assertSee('name="finance_review_to"', false)
+ ->assertSee('data-date-picker', false)
+ ->assertSee('data-date-calendar', false)
+ ->assertSee("window.CaiWuDatePicker.setup(form)", false)
+ ->assertSee('结束日期不能早于开始日期。')
+ ->assertSee('data-installation-fee-open', false)
+ ->assertSee('id="installation-fee-dialog"', false)
+ ->assertSeeText('安装费金额')
+ ->assertSeeText('按客服审核时间汇总安装明细的结算明细金额')
+ ->assertSeeText('店铺名称')
+ ->assertSeeText('安装公司')
+ ->assertSeeText('收款渠道')
+ ->assertSeeText('结算明细金额')
+ ->assertSee('refresh(true)', false)
+ ->assertSee("dispatchEvent(new Event('change'", false)
+ ->assertSee('installation-details-table-wrap', false)
+ ->assertSee('installation-details-table', false)
+ ->assertSee('writing-mode: horizontal-tb', false)
+ ->assertSee('text-overflow: ellipsis', false)
+ ->assertSeeText('INSTALL-TEST-001')
+ ->assertSeeText('测试安装公司')
+ ->assertSeeText('120.50')
+ ->assertSeeText('共 1 条');
+ }
+
+ public function testInstallationDetailsExportUsesFiltersAndMirrorRows(): void
+ {
+ $row = array_fill_keys(array_keys(InstallationDetail::columnLabels()), null);
+ $row['installation_no'] = 'INSTALL-EXPORT-001';
+ $mirror = Mockery::mock(InstallationDetailMirrorRepository::class);
+ $mirror->shouldReceive('paginate')->once()->with(1, 100, ['created_from' => '2026-08-01'])->andReturn([
+ 'ready' => true,
+ 'total' => 1,
+ 'data' => [$row],
+ ]);
+ $this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
+
+ $response = $this->loginAs()->get(route('business.installation-details.export', [
+ 'created_from' => '2026-08-01',
+ ]));
+
+ $response->assertOk()->assertHeader('content-type', 'text/csv; charset=UTF-8');
+ $this->assertStringContainsString('INSTALL-EXPORT-001', $response->streamedContent());
+ }
+
+ public function testInstallationDetailsEndpointReturnsChildItemsForMainRecord(): void
+ {
+ $mirror = Mockery::mock(InstallationDetailMirrorRepository::class);
+ $mirror->shouldReceive('details')->once()->with('guid-install-test')->andReturn([
+ 'ready' => true,
+ 'data' => [[
+ 'transaction_no' => 'INSTALL-TEST-001',
+ 'product_id' => '10035070813217',
+ 'product_name' => '测试商品',
+ 'detail_requested_amount' => '53.59',
+ 'detail_requested_quantity' => 1,
+ ]],
+ ]);
+ $this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
+
+ $this->loginAs()
+ ->get(route('business.installation-details.details', ['guid' => 'guid-install-test']))
+ ->assertOk()
+ ->assertJsonPath('data.0.transaction_no', 'INSTALL-TEST-001')
+ ->assertJsonPath('data.0.product_name', '测试商品');
+ }
+
+ public function testInstallationSummaryEndpointReturnsFeeRowsForCustomerReviewRange(): void
+ {
+ $mirror = Mockery::mock(InstallationDetailMirrorRepository::class);
+ $mirror->shouldReceive('feeDetails')->once()->with([
+ 'customer_review_from' => '2026-09-01',
+ 'customer_review_to' => '2026-09-30',
+ ])->andReturn([
+ 'ready' => true,
+ 'total' => 2000.50,
+ 'rows' => [
+ ['store_name' => '店铺A', 'installation_company' => '万师傅', 'payment_channel' => '包安装', 'amount' => 1250.50],
+ ['store_name' => '店铺B', 'installation_company' => '鲁班到家', 'payment_channel' => '不包安装', 'amount' => 750.00],
+ ],
+ ]);
+ $this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
+
+ $this->loginAs()
+ ->getJson(route('business.installation-details.summary', [
+ 'customer_review_from' => '2026-09-01',
+ 'customer_review_to' => '2026-09-30',
+ ]))
+ ->assertOk()
+ ->assertJsonPath('total', 2000.5)
+ ->assertJsonPath('rows.0.store_name', '店铺A')
+ ->assertJsonPath('rows.0.payment_channel', '包安装')
+ ->assertJsonPath('rows.1.payment_channel', '不包安装');
+ }
+
+ public function testInstallationSummaryRequiresCompleteCustomerReviewRange(): void
+ {
+ $mirror = Mockery::mock(InstallationDetailMirrorRepository::class);
+ $mirror->shouldNotReceive('feeDetails');
+ $this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
+
+ $this->loginAs()
+ ->getJson(route('business.installation-details.summary', [
+ 'customer_review_from' => '2026-09-01',
+ ]))
+ ->assertUnprocessable()
+ ->assertJsonPath('message', '请选择完整的客服审核起止日期');
+ }
+
+ public function testInstallationChildEndpointRequiresTheSameSidebarPermission(): void
+ {
+ $this->withExceptionHandling();
+ $this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
+ setting()->set('system.sidebar_permissions', ['configured'=>['user_'.$this->user->id], 'subjects'=>['user_'.$this->user->id=>['store-management/basic-information']]]);
+ $mirror=Mockery::mock(InstallationDetailMirrorRepository::class);
+ $mirror->shouldNotReceive('details');
+ $mirror->shouldNotReceive('feeDetails');
+ $this->app->instance(InstallationDetailMirrorRepository::class,$mirror);
+ $this->loginAs()->getJson(route('business.installation-details.details',['guid'=>'guid-install-test']))->assertForbidden();
+ $this->loginAs()->getJson(route('business.installation-details.summary', [
+ 'customer_review_from' => '2026-09-01',
+ 'customer_review_to' => '2026-09-30',
+ ]))->assertForbidden();
+ }
+
public function testCompanyInformationUsesWorkbookHeadersAndReadOnlyUpstreamRows(): void
{
$this->mockUpstreamPage((object) [
diff --git a/tests/Feature/Business/PerformanceDetailsMetricDetailsTest.php b/tests/Feature/Business/PerformanceDetailsMetricDetailsTest.php
index 271dd6cd..18140e46 100644
--- a/tests/Feature/Business/PerformanceDetailsMetricDetailsTest.php
+++ b/tests/Feature/Business/PerformanceDetailsMetricDetailsTest.php
@@ -165,84 +165,6 @@ class PerformanceDetailsMetricDetailsTest extends FeatureTestCase
], $response->json('rows'));
}
- public function test_installation_fee_metric_uses_shipment_month_and_channel_category(): void
- {
- $mirror = DB::connection('business_mirror');
- foreach ([
- 'jiansun.dbo.fys_Ticketing_install_view',
- 'jiansun.dbo.fys_Ticketing_install_item',
- ] as $dataset) {
- $mirror->table('business_mirror_table_states')->insert([
- 'dataset_key' => $dataset,
- 'initial_completed_at' => '2026-09-01 00:00:00',
- ]);
- }
- $mirror->table('js_fys_ticketing_install_view')->insert([
- [
- 'Id' => 1,
- 'SourceGuid' => 'install-a',
- 'ShipTIme' => '2026-09-10 12:00:00',
- 'shop_name' => '店铺A',
- 'installCompanyName' => '万师傅',
- 'costWay' => null,
- ],
- [
- 'Id' => 2,
- 'SourceGuid' => 'install-b',
- 'ShipTIme' => '2026-09-11 12:00:00',
- 'shop_name' => '店铺A',
- 'installCompanyName' => '万师傅',
- 'costWay' => '银行转账',
- ],
- [
- 'Id' => 3,
- 'SourceGuid' => 'install-old',
- 'ShipTIme' => '2026-08-31 23:59:59',
- 'shop_name' => '店铺旧',
- 'installCompanyName' => '鲁班到家',
- 'costWay' => null,
- ],
- ]);
- $mirror->table('js_fys_ticketing_install_item')->insert([
- ['Id' => 1, 'guid' => 'install-a', 'settleItemAmount' => 120],
- ['Id' => 2, 'guid' => 'install-b', 'settleItemAmount' => 80],
- ['Id' => 3, 'guid' => 'install-old', 'settleItemAmount' => 999],
- ]);
-
- $response = $this->loginAs()->getJson(route(
- 'business.financial-operations.performance-details.installation-fee-details',
- ['month' => '2026-09'],
- ));
-
- $response
- ->assertOk()
- ->assertJsonPath('label', '安装费金额')
- ->assertJsonPath('month', '2026-09')
- ->assertJsonPath('total', 200)
- ->assertJsonPath('rows.0.store_name', '店铺A')
- ->assertJsonPath('rows.0.installation_company', '万师傅')
- ->assertJsonPath('rows.0.payment_channel', '包安装')
- ->assertJsonPath('rows.0.amount', 120)
- ->assertJsonPath('rows.1.payment_channel', '不包安装')
- ->assertJsonPath('rows.1.amount', 80);
-
- $this->loginAs()
- ->get(route('business.pages.show', [
- 'section' => 'financial-operations',
- 'page' => 'performance-details',
- 'month' => '2026-09',
- ]))
- ->assertOk()
- ->assertSeeText('安装费金额')
- ->assertSee('data-installation-fee-open', false)
- ->assertSee('id="installation-fee-dialog"', false)
- ->assertSeeText('店铺名称')
- ->assertSeeText('安装公司')
- ->assertSeeText('收款渠道')
- ->assertSeeText('结算明细金额')
- ->assertSee('installation-fee-details', false);
- }
-
private function row(string $rowKey, string $storeName, float $offset, string $platform = '京东账单'): array
{
return [
@@ -292,23 +214,6 @@ class PerformanceDetailsMetricDetailsTest extends FeatureTestCase
private function createMirrorTables(): void
{
$schema = Schema::connection('business_mirror');
- $schema->create('business_mirror_table_states', function (Blueprint $table): void {
- $table->string('dataset_key')->primary();
- $table->dateTime('initial_completed_at')->nullable();
- });
- $schema->create('js_fys_ticketing_install_view', function (Blueprint $table): void {
- $table->integer('Id');
- $table->string('SourceGuid');
- $table->dateTime('ShipTIme')->nullable();
- $table->string('shop_name')->nullable();
- $table->string('installCompanyName')->nullable();
- $table->string('costWay')->nullable();
- });
- $schema->create('js_fys_ticketing_install_item', function (Blueprint $table): void {
- $table->integer('Id');
- $table->string('guid');
- $table->decimal('settleItemAmount', 18, 4)->nullable();
- });
$schema->create('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->date('report_month');
$table->string('platform');
diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php
index 228bbee8..1dcaae45 100644
--- a/tests/Feature/Settings/NavigationTest.php
+++ b/tests/Feature/Settings/NavigationTest.php
@@ -113,6 +113,7 @@ class NavigationTest extends FeatureTestCase
->assertSee('minimumFractionDigits: 2', false)
->assertSee('maximumFractionDigits: 2', false)
->assertDontSeeText('查看完整字段与差额判定规则')
+ ->assertDontSee('data-installation-fee-open', false)
->assertDontSee('receipt-method-guide', false)
->assertDontSee('receipt-match-summary', false)
->assertDontSee('data-column-key="product_amount"', false)
@@ -134,21 +135,6 @@ class NavigationTest extends FeatureTestCase
->assertSeeText('店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。');
}
- public function testStandaloneInstallationDetailsPageIsRemoved(): void
- {
- $this->assertFalse(\Illuminate\Support\Facades\Route::has('business.installation-details.details'));
- $this->assertFalse(\Illuminate\Support\Facades\Route::has('business.installation-details.summary'));
- $this->assertFalse(\Illuminate\Support\Facades\Route::has('business.installation-details.export'));
-
- $this->withExceptionHandling();
- $this->loginAs()
- ->get(route('business.pages.show', [
- 'section' => 'financial-operations',
- 'page' => 'installation-details',
- ]))
- ->assertRedirect();
- }
-
public function testExpenseDetailsPageIsAvailableAndForcedToExpenseCategory(): void
{
$this->loginAs()
diff --git a/tests/Unit/EcommerceFinance/InstallationDetailMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/InstallationDetailMirrorRepositoryTest.php
new file mode 100644
index 00000000..7290b6f3
--- /dev/null
+++ b/tests/Unit/EcommerceFinance/InstallationDetailMirrorRepositoryTest.php
@@ -0,0 +1,171 @@
+['driver'=>'sqlite','database'=>':memory:','prefix'=>''], 'upstream_mirrors.connection'=>'business_mirror']);
+ DB::purge('business_mirror');
+ $definitions=[];
+ foreach ([InstallationDetailMirrorRepository::DATASET=>'fixture_install',InstallationDetailMirrorRepository::ITEM_DATASET=>'fixture_items'] as $key=>$table) {
+ $definitions[$key]=['source_key'=>'jiansun','schema'=>'dbo','object'=>explode('.',$key)[2],'target'=>$table,'keys'=>['Id']];
+ }
+ $definitions[InstallationDetailMirrorRepository::DATASET]['minimum_datetime'] = [
+ 'column' => 'ShipTIme',
+ 'value' => '2026-01-01 00:00:00',
+ ];
+ config(['upstream_mirrors.datasets'=>$definitions]);
+ $schema=Schema::connection('business_mirror');
+ $schema->create('business_mirror_table_states',function(Blueprint $t){$t->string('dataset_key')->primary();$t->dateTime('initial_completed_at')->nullable();});
+ $schema->create('fixture_install',function(Blueprint $t){
+ $t->integer('Id');
+ foreach(['SourceGuid','OrderNum','ordernum_id','shop_name','SubmitTime','ShipTIme','kfshTime','cwshTime','apistatus','kfsh','cwsh','BidStatus','costtaker','costWay','installComplete','Delete','installCompanyName','TypeDto'] as $c) $t->string($c)->nullable();
+ $t->integer('StatusId')->nullable()->default(0);
+ foreach(['installcost','settleAmount','acceptAmount'] as $c) $t->decimal($c,14,2)->default(0);
+ });
+ $schema->create('fixture_items',function(Blueprint $t){
+ $t->integer('Id');
+ $t->string('guid');
+ $t->string('shopproid');
+ $t->string('Sourceno')->nullable();
+ $t->dateTime('kfshTime')->nullable();
+ $t->decimal('settleItemAmount',14,2)->default(0);
+ $t->decimal('acceptItemAmount',14,2)->default(0);
+ });
+ foreach(array_keys($definitions) as $key) DB::connection('business_mirror')->table('business_mirror_table_states')->insert(['dataset_key'=>$key,'initial_completed_at'=>'2026-08-01']);
+ DB::connection('business_mirror')->table('fixture_install')->insert([
+ ['Id'=>1,'SourceGuid'=>'A','OrderNum'=>'OLD','shop_name'=>'旧店','SubmitTime'=>'2026-08-01','ShipTIme'=>'2026-08-02','installcost'=>500],
+ ['Id'=>2,'SourceGuid'=>'A','OrderNum'=>'NEW','shop_name'=>'测试店','SubmitTime'=>'2026-08-20 23:59:59','ShipTIme'=>'2026-08-21','installcost'=>20],
+ ['Id'=>3,'SourceGuid'=>'B','OrderNum'=>'OTHER','shop_name'=>'其他店','SubmitTime'=>'2026-08-20','ShipTIme'=>'2026-08-21','installcost'=>30],
+ ['Id'=>4,'SourceGuid'=>'C','OrderNum'=>'NEXT','shop_name'=>'测试店','SubmitTime'=>'2026-08-21','ShipTIme'=>'2026-08-22','installcost'=>40],
+ ['Id'=>5,'SourceGuid'=>'D','OrderNum'=>'PRE-2026','shop_name'=>'测试店','SubmitTime'=>'2026-08-21','ShipTIme'=>'2025-12-31 23:59:59','installcost'=>999],
+ ['Id'=>6,'SourceGuid'=>'E','OrderNum'=>'NULL-SHIP','shop_name'=>'测试店','SubmitTime'=>'2026-08-21','ShipTIme'=>null,'installcost'=>999],
+ ]);
+ DB::connection('business_mirror')->table('fixture_items')->insert([
+ ['Id'=>1,'guid'=>'A','shopproid'=>'PRODUCT-A'],['Id'=>2,'guid'=>'A','shopproid'=>'PRODUCT-A'],
+ ]);
+ $this->repository=new InstallationDetailMirrorRepository(new GenericBusinessMirrorRepository(app('db')),new BusinessMirrorDatasetRegistry());
+ }
+
+ public function test_filters_apply_to_deduplicated_list_count_and_summary(): void
+ {
+ $filters=['shop_name'=>'测试店'];
+ $page=$this->repository->paginate(2,1,$filters);
+ $this->assertSame(2,$page['total']);
+ $this->assertSame(2,$page['data'][0]['id']);
+ $this->assertSame(60.0,$this->repository->summary($filters)['requested']);
+ $filters['created_to']='2026-08-20';
+ $this->assertSame(1,$this->repository->paginate(1,50,$filters)['total']);
+ $this->assertSame(20.0,$this->repository->summary($filters)['requested']);
+ $this->assertSame(0,$this->repository->paginate(1,50,['shop_name'=>"' OR 1=1 --"])['total']);
+ }
+
+ public function test_product_filter_does_not_multiply_main_rows(): void
+ {
+ $filters=['product_id'=>'PRODUCT-A'];
+ $this->assertSame(1,$this->repository->paginate(1,50,$filters)['total']);
+ $this->assertSame(20.0,$this->repository->summary($filters)['requested']);
+ $this->assertSame('NEW',$this->repository->details('A')['data'][0]['transaction_no']);
+ $this->assertSame([],$this->repository->details('missing')['data']);
+ }
+
+ public function test_shipment_cutoff_hides_old_and_null_records_from_list_summary_and_details(): void
+ {
+ DB::connection('business_mirror')->table('fixture_items')->insert([
+ ['Id'=>30,'guid'=>'D','shopproid'=>'OLD-PRODUCT'],
+ ['Id'=>31,'guid'=>'E','shopproid'=>'NULL-PRODUCT'],
+ ]);
+
+ $this->assertSame(3,$this->repository->paginate(1,50)['total']);
+ $this->assertSame(3,$this->repository->summary()['rows']);
+ $this->assertSame([],$this->repository->details('D')['data']);
+ $this->assertSame([],$this->repository->details('E')['data']);
+ }
+
+ public function test_status_filters_and_child_limit_are_explicit(): void
+ {
+ DB::connection('business_mirror')->table('fixture_install')->where('Id',2)->update(['kfsh'=>'1','Delete'=>'0','StatusId'=>2]);
+ $this->assertSame(1,$this->repository->paginate(1,50,['customer_review'=>'1','delete_status'=>'0','arranged_status'=>'1'])['total']);
+ $rows=[];for($i=1;$i<=101;$i++)$rows[]=['Id'=>$i+10,'guid'=>'B','shopproid'=>'P'.$i];
+ DB::connection('business_mirror')->table('fixture_items')->insert($rows);
+ $result=$this->repository->details('B');
+ $this->assertCount(100,$result['data']);
+ $this->assertTrue($result['has_more']);
+ }
+
+ public function test_unset_status_filters_include_null_values_and_render_explicit_labels(): void
+ {
+ DB::connection('business_mirror')->table('fixture_install')->where('Id',3)->update(['StatusId'=>null]);
+
+ foreach (['customer_review','finance_review','completed_status','delete_status','arranged_status'] as $key) {
+ $this->assertSame(3,$this->repository->paginate(1,50,[$key=>'0'])['total'],$key);
+ }
+
+ $other=$this->repository->paginate(1,50,['shop_name'=>'其他店'])['data'][0];
+ $this->assertSame('未审核',$other['customer_service_review']);
+ $this->assertSame('未审核',$other['finance_review']);
+ $this->assertSame('否',$other['completed_status']);
+ $this->assertSame('未安排',$other['arranged_status']);
+
+ DB::connection('business_mirror')->table('fixture_install')->where('Id',2)->update([
+ 'kfsh'=>1,
+ 'cwsh'=>1,
+ 'installComplete'=>1,
+ 'Delete'=>0,
+ 'StatusId'=>1,
+ ]);
+ foreach (['customer_review','finance_review','completed_status','arranged_status'] as $key) {
+ $this->assertSame(1,$this->repository->paginate(1,50,[$key=>'1'])['total'],$key);
+ }
+ }
+
+ public function test_fee_details_use_customer_review_time_and_latest_main_channel(): void
+ {
+ DB::connection('business_mirror')->table('fixture_install')->where('Id',1)->update(['installCompanyName'=>'旧公司']);
+ DB::connection('business_mirror')->table('fixture_install')->where('Id',2)->update(['installCompanyName'=>'万师傅','costWay'=>null]);
+ DB::connection('business_mirror')->table('fixture_install')->where('Id',3)->update(['installCompanyName'=>'鲁班到家','costWay'=>'银行转账']);
+ DB::connection('business_mirror')->table('fixture_install')->where('Id',4)->update(['installCompanyName'=>'万师傅']);
+ DB::connection('business_mirror')->table('fixture_items')->insert([
+ ['Id'=>20,'guid'=>'A','shopproid'=>'P1','Sourceno'=>'A-1','kfshTime'=>'2026-09-01 09:00:00','settleItemAmount'=>60.10,'acceptItemAmount'=>40.00],
+ ['Id'=>21,'guid'=>'A','shopproid'=>'P2','Sourceno'=>'A-1','kfshTime'=>'2026-09-30 18:00:00','settleItemAmount'=>39.90,'acceptItemAmount'=>0.00],
+ ['Id'=>22,'guid'=>'B','shopproid'=>'P3','Sourceno'=>'B-1','kfshTime'=>'2026-09-15 10:00:00','settleItemAmount'=>80.00,'acceptItemAmount'=>70.00],
+ ['Id'=>23,'guid'=>'C','shopproid'=>'P4','Sourceno'=>'C-1','kfshTime'=>'2026-10-01 00:00:00','settleItemAmount'=>500.00,'acceptItemAmount'=>500.00],
+ ['Id'=>24,'guid'=>'D','shopproid'=>'OLD','Sourceno'=>'D-1','kfshTime'=>'2026-09-10 10:00:00','settleItemAmount'=>999.00,'acceptItemAmount'=>999.00],
+ ['Id'=>25,'guid'=>'E','shopproid'=>'NULL','Sourceno'=>'E-1','kfshTime'=>'2026-09-10 10:00:00','settleItemAmount'=>999.00,'acceptItemAmount'=>999.00],
+ ]);
+
+ $result=$this->repository->feeDetails([
+ 'customer_review_from'=>'2026-09-01',
+ 'customer_review_to'=>'2026-09-30',
+ ]);
+
+ $this->assertTrue($result['ready']);
+ $this->assertSame([
+ ['store_name'=>'测试店','installation_company'=>'万师傅','payment_channel'=>'包安装','amount'=>100.0],
+ ['store_name'=>'其他店','installation_company'=>'鲁班到家','payment_channel'=>'不包安装','amount'=>80.0],
+ ],$result['rows']);
+ $this->assertSame(180.0,$result['total']);
+ }
+
+ public function test_end_date_alone_is_valid_and_invalid_status_is_rejected(): void
+ {
+ request()->merge(['created_to'=>'2026-08-20']);
+ $this->assertSame(['created_to'=>'2026-08-20'],InstallationDetailMirrorRepository::requestFilters());
+ request()->merge(['arranged_status'=>'unexpected']);
+ $this->expectException(\Illuminate\Validation\ValidationException::class);
+ InstallationDetailMirrorRepository::requestFilters();
+ }
+}
diff --git a/tests/Unit/EcommerceFinance/InstallationFeeSummaryRepositoryTest.php b/tests/Unit/EcommerceFinance/InstallationFeeSummaryRepositoryTest.php
deleted file mode 100644
index c47382ed..00000000
--- a/tests/Unit/EcommerceFinance/InstallationFeeSummaryRepositoryTest.php
+++ /dev/null
@@ -1,119 +0,0 @@
- [
- 'driver' => 'sqlite',
- 'database' => ':memory:',
- 'prefix' => '',
- ],
- 'upstream_mirrors.connection' => 'business_mirror',
- 'cache.default' => 'array',
- 'upstream_mirrors.datasets' => [
- InstallationFeeSummaryRepository::MAIN_DATASET => [
- 'source_key' => 'jiansun',
- 'schema' => 'dbo',
- 'object' => 'install_view',
- 'target' => 'fixture_install_view',
- 'keys' => ['Id'],
- ],
- InstallationFeeSummaryRepository::ITEM_DATASET => [
- 'source_key' => 'jiansun',
- 'schema' => 'dbo',
- 'object' => 'install_item',
- 'target' => 'fixture_install_item',
- 'keys' => ['Id'],
- ],
- ],
- ]);
- DB::purge('business_mirror');
- Cache::flush();
-
- $schema = Schema::connection('business_mirror');
- $schema->create('business_mirror_table_states', function (Blueprint $table): void {
- $table->string('dataset_key')->primary();
- $table->dateTime('initial_completed_at')->nullable();
- });
- $schema->create('fixture_install_view', function (Blueprint $table): void {
- $table->integer('Id');
- $table->string('SourceGuid');
- $table->dateTime('ShipTIme')->nullable();
- $table->string('shop_name')->nullable();
- $table->string('installCompanyName')->nullable();
- $table->string('costWay')->nullable();
- });
- $schema->create('fixture_install_item', function (Blueprint $table): void {
- $table->integer('Id');
- $table->string('guid');
- $table->decimal('settleItemAmount', 18, 4)->nullable();
- });
- foreach ([InstallationFeeSummaryRepository::MAIN_DATASET, InstallationFeeSummaryRepository::ITEM_DATASET] as $dataset) {
- DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
- 'dataset_key' => $dataset,
- 'initial_completed_at' => '2026-09-01 00:00:00',
- ]);
- }
-
- $mirror = DB::connection('business_mirror');
- $mirror->table('fixture_install_view')->insert([
- ['Id' => 1, 'SourceGuid' => 'A', 'ShipTIme' => '2026-09-01 10:00:00', 'shop_name' => '店铺A', 'installCompanyName' => '万师傅', 'costWay' => null],
- ['Id' => 2, 'SourceGuid' => 'A', 'ShipTIme' => '2026-09-02 10:00:00', 'shop_name' => '店铺A', 'installCompanyName' => '万师傅', 'costWay' => null],
- ['Id' => 3, 'SourceGuid' => 'B', 'ShipTIme' => '2026-09-03 10:00:00', 'shop_name' => '店铺A', 'installCompanyName' => '万师傅', 'costWay' => '银行转账'],
- ['Id' => 4, 'SourceGuid' => 'C', 'ShipTIme' => '2026-08-31 23:59:59', 'shop_name' => '店铺C', 'installCompanyName' => '鲁班到家', 'costWay' => null],
- ['Id' => 5, 'SourceGuid' => 'D', 'ShipTIme' => '2026-09-04 10:00:00', 'shop_name' => null, 'installCompanyName' => ' ', 'costWay' => ' '],
- ]);
- $mirror->table('fixture_install_item')->insert([
- ['Id' => 10, 'guid' => 'A', 'settleItemAmount' => 100],
- ['Id' => 11, 'guid' => 'A', 'settleItemAmount' => 25],
- ['Id' => 12, 'guid' => 'B', 'settleItemAmount' => 50],
- ['Id' => 13, 'guid' => 'C', 'settleItemAmount' => 999],
- ['Id' => 14, 'guid' => 'D', 'settleItemAmount' => 10],
- ]);
-
- $this->repository = new InstallationFeeSummaryRepository(
- new GenericBusinessMirrorRepository(app('db')),
- new BusinessMirrorDatasetRegistry(),
- );
- }
-
- public function test_summary_and_details_use_shipment_month_and_settlement_detail_amount(): void
- {
- $summary = $this->repository->summary('2026-09');
- $this->assertTrue($summary['ready']);
- $this->assertSame(185.0, $summary['total']);
- $this->assertSame(4, $summary['row_count']);
-
- $details = $this->repository->details('2026-09');
- $this->assertTrue($details['ready']);
- $this->assertSame(185.0, $details['total']);
- $this->assertSame([
- ['store_name' => '店铺A', 'installation_company' => '万师傅', 'payment_channel' => '包安装', 'amount' => 125.0],
- ['store_name' => '店铺A', 'installation_company' => '万师傅', 'payment_channel' => '不包安装', 'amount' => 50.0],
- ['store_name' => '未填写店铺', 'installation_company' => '未填写安装公司', 'payment_channel' => '包安装', 'amount' => 10.0],
- ], $details['rows']);
- }
-
- public function test_invalid_month_is_rejected(): void
- {
- $this->expectException(\InvalidArgumentException::class);
- $this->repository->summary('2026-13');
- }
-}
From 72e7f254b7501e41c41ccd75cef65c01209b88ad Mon Sep 17 00:00:00 2001
From: chenxi
Date: Mon, 26 Oct 2026 14:14:26 +0800
Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=E9=99=90=E5=88=B6=E4=BA=BA=E5=B7=A5?=
=?UTF-8?q?=E8=B4=B9=E7=94=A8=E9=A1=B5=E9=9D=A2=E6=9F=A5=E7=9C=8B=E8=B4=A6?=
=?UTF-8?q?=E5=8F=B7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
仅允许晨曦、江陵查看人工费用页面,并在菜单和直接路由统一校验。
---
app/Http/Kernel.php | 1 +
.../EnsureBusinessPageViewerAccess.php | 29 ++++++
app/Utilities/SidebarPermissions.php | 46 +++++++++-
config/business_page_access.php | 19 ++++
routes/admin.php | 34 +++----
.../Business/LaborExpensePageAccessTest.php | 89 +++++++++++++++++++
6 files changed, 201 insertions(+), 17 deletions(-)
create mode 100644 app/Http/Middleware/EnsureBusinessPageViewerAccess.php
create mode 100644 config/business_page_access.php
create mode 100644 tests/Feature/Business/LaborExpensePageAccessTest.php
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index 907e47df..c669c6e3 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -160,6 +160,7 @@ class Kernel extends HttpKernel
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'business.page' => \App\Http\Middleware\EnsureBusinessPageAccess::class,
+ 'business.page.viewer' => \App\Http\Middleware\EnsureBusinessPageViewerAccess::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'cookies.encrypt' => \App\Http\Middleware\EncryptCookies::class,
diff --git a/app/Http/Middleware/EnsureBusinessPageViewerAccess.php b/app/Http/Middleware/EnsureBusinessPageViewerAccess.php
new file mode 100644
index 00000000..30f7c4c6
--- /dev/null
+++ b/app/Http/Middleware/EnsureBusinessPageViewerAccess.php
@@ -0,0 +1,29 @@
+user();
+ $section = (string) $request->route('section');
+ $page = (string) $request->route('page');
+
+ if ($page === '') {
+ return $next($request);
+ }
+
+ abort_unless(
+ $user && SidebarPermissions::canViewPage($user, $section, $page),
+ 403,
+ '当前账号没有该页面权限。',
+ );
+
+ return $next($request);
+ }
+}
diff --git a/app/Utilities/SidebarPermissions.php b/app/Utilities/SidebarPermissions.php
index 7ac43875..fb0cd52c 100644
--- a/app/Utilities/SidebarPermissions.php
+++ b/app/Utilities/SidebarPermissions.php
@@ -104,7 +104,7 @@ class SidebarPermissions
$allowed = self::allowedKeys($user);
if ($allowed === null) {
- return $navigation;
+ return self::applyPageViewerAllowlists($navigation, $user);
}
// 费用明细承接原「广告费用 / 营销费用」入口,并复用账单明细的数据权限。
@@ -285,7 +285,49 @@ class SidebarPermissions
}
}
- return $navigation;
+ return self::applyPageViewerAllowlists($navigation, $user);
+ }
+
+ public static function canViewPage(User $user, string $section, string $page): bool
+ {
+ $pageKey = trim($section, '/') . '/' . trim($page, '/');
+ $allowedViewers = config('business_page_access.page_viewers')[$pageKey] ?? null;
+
+ if (! is_array($allowedViewers)) {
+ return true;
+ }
+
+ return in_array(trim((string) $user->name), $allowedViewers, true);
+ }
+
+ private static function applyPageViewerAllowlists(array $navigation, User $user): array
+ {
+ if (empty(config('business_page_access.page_viewers', []))) {
+ return $navigation;
+ }
+
+ return array_values(array_filter(array_map(
+ static function (array $section) use ($user): ?array {
+ $sectionSlug = (string) ($section['slug'] ?? '');
+ $pages = $section['pages'] ?? [];
+
+ if ($pages === []) {
+ return $section;
+ }
+
+ $section['pages'] = array_values(array_filter(
+ $pages,
+ static fn (array $page): bool => self::canViewPage(
+ $user,
+ $sectionSlug,
+ (string) ($page['slug'] ?? ''),
+ ),
+ ));
+
+ return $section['pages'] === [] ? null : $section;
+ },
+ $navigation,
+ )));
}
public static function allKeys(): array
diff --git a/config/business_page_access.php b/config/business_page_access.php
new file mode 100644
index 00000000..eec1d370
--- /dev/null
+++ b/config/business_page_access.php
@@ -0,0 +1,19 @@
+ [
+ 'financial-operations/labor-expenses' => [
+ '晨曦',
+ '江陵',
+ ],
+ ],
+];
diff --git a/routes/admin.php b/routes/admin.php
index 9f24576e..31937370 100644
--- a/routes/admin.php
+++ b/routes/admin.php
@@ -568,11 +568,13 @@ Route::get('workspace/courier-payables/third-party-warehouse-storage/settlement-
->middleware('business.page:courier-payables,third-party-warehouse-storage')
->name('business.courier-payables.third-party-warehouse-storage.settlement-export');
-Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export')
- ->name('business.pages.export');
+Route::middleware('business.page.viewer')->group(function (): void {
+ Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export')
+ ->name('business.pages.export');
-Route::get('workspace/{section}/{page}/{tab}/template', 'Business\Pages@storeReceivingAccountTemplate')
- ->name('business.pages.store-receiving-account-template');
+ Route::get('workspace/{section}/{page}/{tab}/template', 'Business\Pages@storeReceivingAccountTemplate')
+ ->name('business.pages.store-receiving-account-template');
+});
Route::get('workspace/fund-management/fund-forecast/template', 'Business\Pages@fundForecastTemplate')
->middleware('business.page:fund-management,fund-forecast')
@@ -810,21 +812,23 @@ Route::get('workspace/financial-ledger/{page?}/{tab?}', function (Request $reque
return redirect()->to($query === [] ? $target : $target . '?' . http_build_query($query));
})->name('business.financial-ledger.legacy');
-Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show')
- ->name('business.pages.show');
+Route::middleware('business.page.viewer')->group(function (): void {
+ Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show')
+ ->name('business.pages.show');
-Route::post('workspace/{section}/{page}/import/{tab?}', 'Business\Pages@import')
- ->middleware('import')
- ->name('business.pages.import');
+ Route::post('workspace/{section}/{page}/import/{tab?}', 'Business\Pages@import')
+ ->middleware('import')
+ ->name('business.pages.import');
-Route::post('workspace/{section}/{page}/{tab?}', 'Business\Pages@store')
- ->name('business.pages.store');
+ Route::post('workspace/{section}/{page}/{tab?}', 'Business\Pages@store')
+ ->name('business.pages.store');
-Route::patch('workspace/{section}/{page}/{id}/{tab?}', 'Business\Pages@update')
- ->name('business.pages.update');
+ Route::patch('workspace/{section}/{page}/{id}/{tab?}', 'Business\Pages@update')
+ ->name('business.pages.update');
-Route::delete('workspace/{section}/{page}/{id}/{tab?}', 'Business\Pages@destroy')
- ->name('business.pages.destroy');
+ Route::delete('workspace/{section}/{page}/{id}/{tab?}', 'Business\Pages@destroy')
+ ->name('business.pages.destroy');
+});
Route::group(['as' => 'system-settings.', 'prefix' => 'system-settings'], function () {
Route::resource('groups', 'SystemSettings\UserGroups')->except('show');
diff --git a/tests/Feature/Business/LaborExpensePageAccessTest.php b/tests/Feature/Business/LaborExpensePageAccessTest.php
new file mode 100644
index 00000000..9f71c36b
--- /dev/null
+++ b/tests/Feature/Business/LaborExpensePageAccessTest.php
@@ -0,0 +1,89 @@
+account('晨曦');
+ $jiangling = $this->account('江陵');
+ $other = $this->account('其他财务');
+
+ $this->assertTrue($this->pageIsVisible($chenxi));
+ $this->assertTrue($this->pageIsVisible($jiangling));
+ $this->assertFalse($this->pageIsVisible($other));
+ $this->assertFalse($this->pageIsVisible($this->user));
+ }
+
+ public function test_explicit_page_permission_cannot_bypass_the_viewer_allowlist(): void
+ {
+ $other = $this->account('其他财务');
+ $subject = 'user_' . $other->id;
+ setting()->set('system.sidebar_permissions', [
+ 'configured' => [$subject],
+ 'subjects' => [
+ $subject => ['financial-operations/labor-expenses'],
+ ],
+ ]);
+
+ $this->assertFalse($this->pageIsVisible($other));
+ }
+
+ public function test_direct_page_and_generic_actions_use_the_same_allowlist(): void
+ {
+ $chenxi = $this->account('晨曦');
+ $other = $this->account('其他财务');
+ $routeParameters = [
+ 'section' => 'financial-operations',
+ 'page' => 'labor-expenses',
+ ];
+
+ $this->loginAs($chenxi)
+ ->get(route('business.pages.show', $routeParameters))
+ ->assertOk();
+
+ $this->withExceptionHandling()->loginAs($other)
+ ->get(route('business.pages.show', $routeParameters))
+ ->assertForbidden();
+ $this->post(route('business.pages.store', $routeParameters), [])
+ ->assertForbidden();
+
+ $this->withExceptionHandling()->loginAs($this->user)
+ ->get(route('business.pages.show', $routeParameters))
+ ->assertForbidden();
+ }
+
+ private function pageIsVisible(User $user): bool
+ {
+ $financialOperations = collect(SidebarPermissions::navigationFor($user))
+ ->firstWhere('slug', 'financial-operations');
+
+ return collect($financialOperations['pages'] ?? [])
+ ->contains('slug', 'labor-expenses');
+ }
+
+ private function account(string $name): User
+ {
+ $suffix = User::count();
+ $user = User::create([
+ 'name' => $name,
+ 'email' => 'labor-access-' . $suffix . '@example.test',
+ 'password' => 'fixture-unused-password',
+ 'enabled' => true,
+ 'locale' => 'zh-CN',
+ 'landing_page' => 'dashboard',
+ ]);
+ $user->roles()->attach(Role::where('name', 'user')->firstOrFail()->id);
+ $user->permissions()->attach(Permission::where('name', 'read-admin-panel')->firstOrFail()->id);
+ $user->companies()->attach($this->company->id);
+
+ return $user;
+ }
+}
From d9dce54ae3b923fdd5698ef59157fcfc26e67970 Mon Sep 17 00:00:00 2001
From: shanshi <3316401569@qq.com>
Date: Mon, 26 Oct 2026 14:29:08 +0800
Subject: [PATCH 4/7] =?UTF-8?q?=E8=B4=A2=E5=8A=A1=E5=8F=B0=E8=B4=A6?=
=?UTF-8?q?=E6=94=AF=E6=8C=81=E7=BB=B4=E6=8A=A4=E5=85=AC=E5=8F=B8=E8=B5=84?=
=?UTF-8?q?=E6=96=99=20(#592)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Controllers/Business/FinanceLedger.php | 15 ++++-
docs/财务台账开发说明.md | 2 +-
.../Services/FinanceLedgerAccountCreator.php | 27 ++++++++
.../pages/financial-ledger/ledger.blade.php | 51 +++++++++++++--
.../FinanceLedgerAccountStatusTest.php | 64 +++++++++++++++++++
5 files changed, 151 insertions(+), 8 deletions(-)
diff --git a/app/Http/Controllers/Business/FinanceLedger.php b/app/Http/Controllers/Business/FinanceLedger.php
index 76b08226..2769a3de 100644
--- a/app/Http/Controllers/Business/FinanceLedger.php
+++ b/app/Http/Controllers/Business/FinanceLedger.php
@@ -48,6 +48,13 @@ class FinanceLedger extends Controller
$openingDate = (string) config('finance_ledger.opening_date', '2026-10-17');
$data = $request->validate([
'company_name' => ['required', 'string', 'max:100'],
+ 'company_register_place' => ['nullable', 'string', 'max:200'],
+ 'company_business_type' => ['nullable', 'string', 'max:50'],
+ 'company_tax_type' => ['nullable', 'string', 'max:50'],
+ 'company_status' => ['nullable', 'string', 'max:20'],
+ 'company_basic_bank' => ['nullable', 'string', 'max:200'],
+ 'company_basic_account_last4' => ['nullable', 'string', 'max:50'],
+ 'company_remark' => ['nullable', 'string', 'max:500'],
'account_short' => ['required', 'string', 'max:100'],
'account_type' => ['nullable', 'string', 'max:50'],
'status' => ['required', 'string', Rule::in(array_keys(FinanceLedgerAccountStatus::OPTIONS))],
@@ -90,9 +97,11 @@ class FinanceLedger extends Controller
$result = $creator->create($data);
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
- $message = $result['company_created']
- ? '已新增公司并建立首个账户。'
- : '已新增账户并关联现有公司。';
+ $message = match (true) {
+ $result['company_created'] => '已新增公司并建立首个账户。',
+ $result['company_updated'] => '已新增账户并更新公司资料。',
+ default => '已新增账户并关联现有公司。',
+ };
return response()->json([
'ok' => true,
diff --git a/docs/财务台账开发说明.md b/docs/财务台账开发说明.md
index 7d12dcea..c5ac60c2 100644
--- a/docs/财务台账开发说明.md
+++ b/docs/财务台账开发说明.md
@@ -64,7 +64,7 @@ docker compose -f docker-compose.local.yml exec -u www-data app \
- 路由:`/workspace/financial-operations/ledger`(旧地址会自动跳转)
- 页面:账户总表、店铺台账、每日资金流水、动账明细、账户映射、数据来源
- 账户总表支持导出空白 Excel 录入模板,包含页面列、状态和账户类型选项及填写说明
-- 账户总表编辑模式支持直接新增公司和首个账户:公司不存在时同步写入公司台账,账户简称必须唯一,可同时登记期初日期与期初余额
+- 账户总表编辑模式支持直接新增公司和首个账户:公司不存在时同步写入公司台账,可维护注册地、企业类型、税务类型、公司状态、基本户信息和备注;账户简称必须唯一,可同时登记期初日期与期初余额
- 写权限:账户映射、账户状态和账户类型修改仅系统管理员及配置的财务台账管理员角色;其他账号只读
- 调度:`finance-ledger:sync-rpt --days=7 --merge` 每天 02:30(Asia/Shanghai)
diff --git a/modules/EcommerceFinance/Services/FinanceLedgerAccountCreator.php b/modules/EcommerceFinance/Services/FinanceLedgerAccountCreator.php
index 64fa93fc..84b0c368 100644
--- a/modules/EcommerceFinance/Services/FinanceLedgerAccountCreator.php
+++ b/modules/EcommerceFinance/Services/FinanceLedgerAccountCreator.php
@@ -21,6 +21,15 @@ class FinanceLedgerAccountCreator
$accountNo = $this->text($data['account_no'] ?? '');
$accountType = $this->text($data['account_type'] ?? '');
$companyPlan = $this->text($data['company_plan'] ?? '');
+ $companyFields = [
+ 'register_place' => $this->nullableText($data['company_register_place'] ?? null),
+ 'business_type' => $this->nullableText($data['company_business_type'] ?? null),
+ 'tax_type' => $this->nullableText($data['company_tax_type'] ?? null),
+ 'status' => $this->nullableText($data['company_status'] ?? null),
+ 'basic_bank' => $this->nullableText($data['company_basic_bank'] ?? null),
+ 'basic_account_last4' => $this->nullableText($data['company_basic_account_last4'] ?? null),
+ 'remark' => $this->nullableText($data['company_remark'] ?? null),
+ ];
$openingDate = (string) ($data['opening_date'] ?? now()->toDateString());
$openingBalance = round((float) ($data['opening_balance'] ?? 0), 2);
$status = FinanceLedgerAccountStatus::normalize((string) ($data['status'] ?? ''));
@@ -38,6 +47,7 @@ class FinanceLedgerAccountCreator
$accountNo,
$accountType,
$companyPlan,
+ $companyFields,
$openingDate,
$openingBalance,
$status,
@@ -55,12 +65,28 @@ class FinanceLedgerAccountCreator
$company = $connection->table('fin_companies')->where('name', $companyName)->first(['id']);
$companyCreated = $company === null;
+ $companyUpdated = false;
if ($companyCreated) {
$connection->table('fin_companies')->insert([
'name' => $companyName,
+ ...$companyFields,
'created_at' => $now,
'updated_at' => $now,
]);
+ } else {
+ $providedCompanyFields = array_filter(
+ $companyFields,
+ static fn (?string $value): bool => $value !== null,
+ );
+ if ($providedCompanyFields !== []) {
+ $connection->table('fin_companies')
+ ->where('id', $company->id)
+ ->update([
+ ...$providedCompanyFields,
+ 'updated_at' => $now,
+ ]);
+ $companyUpdated = true;
+ }
}
$accountId = $connection->table('fin_accounts')->insertGetId([
@@ -118,6 +144,7 @@ class FinanceLedgerAccountCreator
'company_name' => $companyName,
'account_short' => $accountShort,
'company_created' => $companyCreated,
+ 'company_updated' => $companyUpdated,
'status' => $status,
'opening_date' => $openingDate,
'opening_balance' => $openingBalance,
diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php
index 6dbbda1d..62c8603a 100644
--- a/resources/views/business/pages/financial-ledger/ledger.blade.php
+++ b/resources/views/business/pages/financial-ledger/ledger.blade.php
@@ -211,13 +211,20 @@
.ledger-company-form__close { display:inline-grid; flex:0 0 auto; place-items:center; width:1.8rem; height:1.8rem; padding:0; border:1px solid transparent; border-radius:.35rem; background:transparent; color:var(--vip-content-muted, #595959); cursor:pointer; }
.ledger-company-form__close:hover { border-color:var(--vip-border, #D8D2C7); color:var(--vip-content-accent-dark, #856632); }
.ledger-company-form__body { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:.8rem; padding:1rem 1.1rem; }
+ .ledger-company-form__section { grid-column:1 / -1; display:flex; align-items:baseline; gap:.55rem; padding-top:.75rem; border-top:1px solid #e8e3da; color:var(--vip-content-ink, #171717); font-size:.76rem; font-weight:700; }
+ .ledger-company-form__section:first-child { padding-top:0; border-top:0; }
+ .ledger-company-form__section span:last-child { color:var(--vip-content-muted, #595959); font-size:.65rem; font-weight:400; }
.ledger-company-field { display:grid; min-width:0; gap:.3rem; }
.ledger-company-field--wide { grid-column:span 2; }
+ .ledger-company-field--full { grid-column:1 / -1; }
.ledger-company-field label { color:var(--vip-content-muted, #595959); font-size:.7rem; font-weight:600; }
.ledger-company-field input,
- .ledger-company-field select { width:100%; min-width:0; height:2.15rem; padding:0 .58rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.45rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; }
+ .ledger-company-field select,
+ .ledger-company-field textarea { width:100%; min-width:0; min-height:2.15rem; padding:0 .58rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.45rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; }
+ .ledger-company-field textarea { min-height:3.4rem; padding-top:.45rem; padding-bottom:.45rem; resize:vertical; }
.ledger-company-field input:focus,
- .ledger-company-field select:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); }
+ .ledger-company-field select:focus,
+ .ledger-company-field textarea:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); }
.ledger-company-field__hint { color:var(--vip-content-muted, #595959); font-size:.65rem; line-height:1.45; }
.ledger-company-form__message { grid-column:1 / -1; margin:0; padding:.55rem .65rem; border:1px solid rgba(184,0,0,.28); border-radius:.4rem; background:#fff5f5; color:var(--vip-danger, #B80000); font-size:.7rem; line-height:1.5; }
.ledger-company-form__message[hidden] { display:none !important; }
@@ -479,18 +486,54 @@
+
+ 公司资料
+ 除公司名称外均可留空;已有公司只会更新本次填写的字段。
+
输入公司台账中不存在的名称时会自动新增公司。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 账户资料
+ 首个账户会自动关联上方公司。
+
@@ -555,7 +598,7 @@
diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php
index 1c28e9c6..daaf9622 100644
--- a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php
+++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php
@@ -141,6 +141,13 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
->assertSee('data-ledger-company-dialog', false)
->assertSee('新增公司/账户')
->assertSee('name="company_name"', false)
+ ->assertSee('name="company_register_place"', false)
+ ->assertSee('name="company_business_type"', false)
+ ->assertSee('name="company_tax_type"', false)
+ ->assertSee('name="company_status"', false)
+ ->assertSee('name="company_basic_bank"', false)
+ ->assertSee('name="company_basic_account_last4"', false)
+ ->assertSee('name="company_remark"', false)
->assertSee('name="opening_balance"', false)
->assertSee('id="ledger-company-options"', false)
->assertSee('aria-checked="false"', false)
@@ -753,6 +760,13 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
{
$this->loginAs()->postJson($this->createAccountUrl(), [
'company_name' => '新设财务公司',
+ 'company_register_place' => '上海市浦东新区',
+ 'company_business_type' => '有限责任公司',
+ 'company_tax_type' => '一般纳税人',
+ 'company_status' => '正常',
+ 'company_basic_bank' => '中国农业银行上海浦东支行',
+ 'company_basic_account_last4' => '1234',
+ 'company_remark' => '新设公司财务备注',
'account_short' => '新设财务公司农行',
'account_type' => '公户',
'status' => 'limited',
@@ -774,6 +788,13 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
$this->assertDatabaseHas('fin_companies', [
'name' => '新设财务公司',
+ 'register_place' => '上海市浦东新区',
+ 'business_type' => '有限责任公司',
+ 'tax_type' => '一般纳税人',
+ 'status' => '正常',
+ 'basic_bank' => '中国农业银行上海浦东支行',
+ 'basic_account_last4' => '1234',
+ 'remark' => '新设公司财务备注',
], 'business_mirror');
$this->assertDatabaseHas('fin_accounts', [
'company_name' => '新设财务公司',
@@ -823,6 +844,49 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
$this->assertDatabaseCount('fin_companies', 1, 'business_mirror');
}
+ public function test_existing_company_profile_can_be_updated_with_a_new_account(): void
+ {
+ DB::connection('business_mirror')->table('fin_companies')->insert([
+ 'name' => '已有资料公司',
+ 'register_place' => '江苏省苏州市',
+ 'business_type' => '个体工商户',
+ 'tax_type' => '小规模纳税人',
+ 'status' => '存续',
+ 'remark' => '原公司备注',
+ ]);
+
+ $this->loginAs()->postJson($this->createAccountUrl(), [
+ 'company_name' => '已有资料公司',
+ 'company_business_type' => '有限责任公司',
+ 'company_tax_type' => '一般纳税人',
+ 'company_status' => '正常',
+ 'company_basic_bank' => '中国工商银行苏州分行',
+ 'company_basic_account_last4' => '8899',
+ 'company_remark' => '更新后的公司备注',
+ 'account_short' => '已有资料公司工行',
+ 'account_type' => '公户',
+ 'status' => 'normal',
+ 'account_no' => '1102000000008899',
+ 'opening_date' => '2026-10-20',
+ 'opening_balance' => 0,
+ ])
+ ->assertCreated()
+ ->assertJsonPath('account.company_created', false)
+ ->assertJsonPath('account.company_updated', true)
+ ->assertJsonPath('message', '已新增账户并更新公司资料。');
+
+ $this->assertDatabaseHas('fin_companies', [
+ 'name' => '已有资料公司',
+ 'register_place' => '江苏省苏州市',
+ 'business_type' => '有限责任公司',
+ 'tax_type' => '一般纳税人',
+ 'status' => '正常',
+ 'basic_bank' => '中国工商银行苏州分行',
+ 'basic_account_last4' => '8899',
+ 'remark' => '更新后的公司备注',
+ ], 'business_mirror');
+ }
+
public function test_invalid_status_or_account_is_rejected(): void
{
$this->withExceptionHandling()->loginAs()->postJson($this->statusUrl(), [
From 3ae7bf7dc8210d16e22067d53da8aeb731e76296 Mon Sep 17 00:00:00 2001
From: DoDo <1060330844@qq.com>
Date: Mon, 26 Oct 2026 14:30:01 +0800
Subject: [PATCH 5/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BA=97=E9=93=BA?=
=?UTF-8?q?=E7=8A=B6=E6=80=81=E6=98=BE=E7=A4=BA=E4=B8=BA=E4=B8=AD=E6=96=87?=
=?UTF-8?q?=20(#593)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
店铺基础信息状态列统一显示正常、关闭中、已关闭。
---
app/Business/UpstreamBusinessInformation.php | 5 ++++-
.../EcommerceFinance/UpstreamBusinessInformationTest.php | 1 +
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/app/Business/UpstreamBusinessInformation.php b/app/Business/UpstreamBusinessInformation.php
index 70383efd..837dc4a3 100644
--- a/app/Business/UpstreamBusinessInformation.php
+++ b/app/Business/UpstreamBusinessInformation.php
@@ -262,7 +262,10 @@ SQL);
$page,
$perPage,
$filters,
- $this->storesWithAccountOwnership($this->mirror->allStores($storeFilters)),
+ $this->storesWithAccountOwnership(array_map(
+ fn (array $row): array => $this->mapStore($row),
+ $this->mirror->allStores($storeFilters),
+ )),
);
}
diff --git a/tests/Unit/EcommerceFinance/UpstreamBusinessInformationTest.php b/tests/Unit/EcommerceFinance/UpstreamBusinessInformationTest.php
index 19879193..777a3eab 100644
--- a/tests/Unit/EcommerceFinance/UpstreamBusinessInformationTest.php
+++ b/tests/Unit/EcommerceFinance/UpstreamBusinessInformationTest.php
@@ -158,6 +158,7 @@ class UpstreamBusinessInformationTest extends TestCase
$this->assertSame('正确店铺负责人', $paginator->items()[0]->store_manager);
$this->assertSame('正确部门负责人', $paginator->items()[0]->department_manager);
$this->assertNotSame('运营总监', $paginator->items()[0]->department_manager);
+ $this->assertSame('正常', $paginator->items()[0]->store_status);
$summaryCards = collect($this->app['request']->attributes->get('business-page-summary-cards'))->keyBy('label');
$this->assertSame(1, $summaryCards['店铺总数']['value']);
$this->assertSame(1, $summaryCards['正常店铺']['value']);
From 4089cdca93c95cd94ed4fe71fc57fb2d02b03571 Mon Sep 17 00:00:00 2001
From: yueyang <2495544247@qq.com>
Date: Mon, 26 Oct 2026 14:39:45 +0800
Subject: [PATCH 6/7] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=B3=BB=E7=BB=9F?=
=?UTF-8?q?=E5=8C=96=E5=BE=85=E5=8A=9E=E5=86=85=E5=AE=B9=E5=90=88=E5=B9=B6?=
=?UTF-8?q?=E4=B8=8E=E4=B8=89=E7=BA=A7=E5=BA=8F=E5=8F=B7=20(#594)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Business/SystemizationTodos.php | 175 +++++++++++++++---
.../pages/systemization-todos.blade.php | 32 +++-
.../Business/SystemizationTodosTest.php | 95 +++++++++-
3 files changed, 261 insertions(+), 41 deletions(-)
diff --git a/app/Http/Controllers/Business/SystemizationTodos.php b/app/Http/Controllers/Business/SystemizationTodos.php
index 80d8b7fc..650b4c21 100644
--- a/app/Http/Controllers/Business/SystemizationTodos.php
+++ b/app/Http/Controllers/Business/SystemizationTodos.php
@@ -79,33 +79,45 @@ class SystemizationTodos extends Controller
$items = $query
->select($query->qualifyColumn('*'))
- ->selectRaw(
- "ROW_NUMBER() OVER (
- PARTITION BY COALESCE(NULLIF(category, ''), '__uncategorized__')
- ORDER BY
- CASE WHEN status = '已完成' THEN 1 ELSE 0 END,
- CASE WHEN section IS NULL OR section = '' THEN 1 ELSE 0 END,
- section,
- CASE WHEN due_date IS NULL THEN 1 ELSE 0 END,
- due_date,
- sort_order,
- id
- ) AS category_sequence"
- )
- ->orderByRaw("CASE WHEN status = '已完成' THEN 1 ELSE 0 END")
- ->orderByRaw("CASE WHEN category IS NULL OR category = '' THEN 1 ELSE 0 END")
- ->orderBy('category')
- ->orderByRaw("CASE WHEN section IS NULL OR section = '' THEN 1 ELSE 0 END")
- ->orderBy('section')
- ->orderByRaw('CASE WHEN due_date IS NULL THEN 1 ELSE 0 END')
- ->orderBy('due_date')
->orderBy('sort_order')
->orderBy('id')
->get();
- if ($filters['sequence'] > 0) {
+ $sequenceMap = $this->hierarchySequenceMap();
+ $items->each(function (SystemizationTodo $todo) use ($sequenceMap): void {
+ $sequence = $sequenceMap[$todo->id] ?? [
+ 'type_sequence' => 0,
+ 'content_sequence' => 0,
+ 'item_sequence' => 0,
+ 'order' => PHP_INT_MAX,
+ 'category_display' => '',
+ 'section_display' => '',
+ ];
+ $todo->setAttribute('type_sequence', $sequence['type_sequence']);
+ $todo->setAttribute('content_sequence', $sequence['content_sequence']);
+ $todo->setAttribute('item_sequence', $sequence['item_sequence']);
+ $todo->setAttribute('hierarchy_order', $sequence['order']);
+ $todo->setAttribute('category_display', $sequence['category_display']);
+ $todo->setAttribute('section_display', $sequence['section_display']);
+ });
+ $items = $items->sortBy('hierarchy_order')->values();
+ if ($filters['type_sequence'] > 0) {
$items = $items
->filter(static fn (SystemizationTodo $todo): bool =>
- (int) $todo->category_sequence === $filters['sequence']
+ (int) $todo->type_sequence === $filters['type_sequence']
+ )
+ ->values();
+ }
+ if ($filters['content_sequence'] > 0) {
+ $items = $items
+ ->filter(static fn (SystemizationTodo $todo): bool =>
+ (int) $todo->content_sequence === $filters['content_sequence']
+ )
+ ->values();
+ }
+ if ($filters['item_sequence'] > 0) {
+ $items = $items
+ ->filter(static fn (SystemizationTodo $todo): bool =>
+ (int) $todo->item_sequence === $filters['item_sequence']
)
->values();
}
@@ -172,6 +184,121 @@ class SystemizationTodos extends Controller
];
}
+ private function hierarchySequenceMap(): array
+ {
+ $todos = SystemizationTodo::query()
+ ->orderBy('sort_order')
+ ->orderBy('id')
+ ->get(['id', 'category', 'section', 'sort_order']);
+ $rows = $todos->map(function (SystemizationTodo $todo): array {
+ $category = $this->hierarchyLabel($todo->category);
+ $section = $this->hierarchyLabel($todo->section);
+
+ return [
+ 'category_key' => $category === '' ? "\0" : $category,
+ 'category_label' => $category,
+ 'section_key' => $section === '' ? "\0" : $section,
+ 'section_label' => $section,
+ 'sort_order' => (int) $todo->sort_order,
+ 'id' => (int) $todo->id,
+ ];
+ })->all();
+
+ $categoryOrder = [];
+ $categoryLabels = [];
+ $sectionOrder = [];
+ $sectionLabels = [];
+ $groupedRows = [];
+ foreach ($rows as $row) {
+ $categoryKey = $row['category_key'];
+ $sectionKey = $row['section_key'];
+ $categoryOrder[$categoryKey] = min(
+ $categoryOrder[$categoryKey] ?? PHP_INT_MAX,
+ $row['sort_order'],
+ );
+ $categoryLabels[$categoryKey] = $row['category_label'];
+ $sectionOrder[$categoryKey][$sectionKey] = min(
+ $sectionOrder[$categoryKey][$sectionKey] ?? PHP_INT_MAX,
+ $row['sort_order'],
+ );
+ $sectionLabels[$categoryKey][$sectionKey] = $row['section_label'];
+ $groupedRows[$categoryKey][$sectionKey][] = $row;
+ }
+
+ $categoryKeys = array_keys($categoryOrder);
+ usort($categoryKeys, function (string $left, string $right) use (
+ $categoryOrder,
+ $categoryLabels,
+ ): int {
+ return $this->compareHierarchyGroups(
+ $left,
+ $right,
+ $categoryOrder,
+ $categoryLabels,
+ );
+ });
+
+ $sequenceMap = [];
+ $hierarchyOrder = 0;
+ foreach ($categoryKeys as $categoryIndex => $categoryKey) {
+ $contentKeys = array_keys($sectionOrder[$categoryKey]);
+ usort($contentKeys, function (string $left, string $right) use (
+ $categoryKey,
+ $sectionOrder,
+ $sectionLabels,
+ ): int {
+ return $this->compareHierarchyGroups(
+ $left,
+ $right,
+ $sectionOrder[$categoryKey],
+ $sectionLabels[$categoryKey],
+ );
+ });
+
+ foreach ($contentKeys as $contentIndex => $contentKey) {
+ $contentRows = $groupedRows[$categoryKey][$contentKey];
+ usort($contentRows, static fn (array $left, array $right): int =>
+ [$left['sort_order'], $left['id']] <=> [$right['sort_order'], $right['id']]
+ );
+
+ foreach ($contentRows as $itemIndex => $row) {
+ $sequenceMap[$row['id']] = [
+ 'type_sequence' => $categoryIndex + 1,
+ 'content_sequence' => $contentIndex + 1,
+ 'item_sequence' => $itemIndex + 1,
+ 'order' => $hierarchyOrder++,
+ 'category_display' => $row['category_label'],
+ 'section_display' => $row['section_label'],
+ ];
+ }
+ }
+ }
+
+ return $sequenceMap;
+ }
+
+ private function compareHierarchyGroups(
+ string $left,
+ string $right,
+ array $sortOrders,
+ array $labels,
+ ): int {
+ $comparison = ($sortOrders[$left] ?? PHP_INT_MAX) <=> ($sortOrders[$right] ?? PHP_INT_MAX);
+
+ return $comparison !== 0
+ ? $comparison
+ : strnatcasecmp($labels[$left] ?? '', $labels[$right] ?? '');
+ }
+
+ private function hierarchyLabel(?string $value): string
+ {
+ return trim((string) preg_replace(
+ '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、..::)]\s*/u',
+ '',
+ (string) $value,
+ ));
+ }
+
public function store(Request $request): RedirectResponse
{
$data = $this->validated($request);
@@ -280,7 +407,9 @@ class SystemizationTodos extends Controller
'assignees' => $this->peopleFilter('assignee'),
'start_date' => $this->normalizeDateFilter(request('start_date')),
'due_date' => $this->normalizeDateFilter(request('due_date')),
- 'sequence' => max(0, (int) request('sequence', 0)),
+ 'type_sequence' => max(0, (int) request('type_sequence', 0)),
+ 'content_sequence' => max(0, (int) request('content_sequence', 0)),
+ 'item_sequence' => max(0, (int) request('item_sequence', 0)),
'keyword' => trim((string) request('keyword', '')),
];
}
diff --git a/resources/views/business/pages/systemization-todos.blade.php b/resources/views/business/pages/systemization-todos.blade.php
index 04a9438d..c9409498 100644
--- a/resources/views/business/pages/systemization-todos.blade.php
+++ b/resources/views/business/pages/systemization-todos.blade.php
@@ -49,7 +49,11 @@
.sys-todo-table__index-cell { width:3.6rem; min-width:3.6rem; text-align:center; }
.sys-todo-table__index { color:#6d5630; font-weight:600; font-variant-numeric:tabular-nums; white-space:nowrap; }
.sys-todo-table__group { padding-top:.9rem; padding-bottom:.9rem; background:#fcfaf5; font-weight:600; line-height:1.7; vertical-align:middle; }
+ .sys-todo-table__group-content { display:inline-flex; align-items:flex-start; gap:.38rem; min-width:0; }
+ .sys-todo-table__group-text { min-width:0; }
+ .sys-todo-table__level { display:inline-flex; flex:0 0 auto; align-items:center; justify-content:center; min-width:1.35rem; height:1.35rem; margin-top:.05rem; border:1px solid #d8c9aa; border-radius:999px; background:#fffaf0; color:var(--sys-gold); font-size:.68rem; font-weight:700; font-variant-numeric:tabular-nums; }
.sys-todo-table__type { position:sticky; left:0; z-index:3; width:5.4rem; min-width:5.4rem; text-align:center; white-space:nowrap; box-shadow:1px 0 0 #e5ddcf; }
+ .sys-todo-table__type .sys-todo-table__group-text { white-space:normal; }
.sys-todo-table__section { min-width:11rem; max-width:17rem; }
.sys-todo-table__title, .sys-todo-table__progress, .sys-todo-table__notes { line-height:1.45; white-space:pre-line; overflow-wrap:anywhere; }
.sys-todo-table__title { min-width:17rem; max-width:24rem; font-weight:600; }
@@ -199,7 +203,9 @@
@endforeach
-
+
+
+
@php
$selectedOwners = $todoOwners->filter(
static fn (string $owner): bool => in_array($owner, $todoFilters['owners'], true)
@@ -266,16 +272,16 @@
));
};
$sectionLabels = $items
- ->map(static fn ($groupItem): string => $cleanDisplayLabel($groupItem->section))
+ ->map(static fn ($groupItem): string => trim((string) $groupItem->section_display))
->all();
$titleLabels = $items
->map(static fn ($groupItem): string => $cleanDisplayLabel($groupItem->title))
->all();
$groupValues = [
- 'category' => $items->map(static fn ($groupItem): string => trim((string) ($groupItem->category ?? '')))->all(),
- 'section' => $items->map(static fn ($groupItem, int $index): string => trim((string) ($groupItem->category ?? ''))
+ 'category' => $items->map(static fn ($groupItem): string => (string) ((int) $groupItem->type_sequence))->all(),
+ 'section' => $items->map(static fn ($groupItem): string => (int) $groupItem->type_sequence
. "\0"
- . ($sectionLabels[$index] ?? ''))->all(),
+ . (int) $groupItem->content_sequence)->all(),
];
$groupRowspans = ['category' => [], 'section' => []];
foreach (['category', 'section'] as $groupField) {
@@ -327,7 +333,7 @@
- | 类型 | 内容 | 序号 | 待办事项 | 总负责人 | 责任人 |
+ 类型 | 内容 | 待办序号 | 待办事项 | 总负责人 | 责任人 |
开始时间 | 完成时间 | 状态 | 完成情况 | 完成截图 | 备注 |
@@ -371,14 +377,22 @@
@endphp
@if ($categorySpan !== 0)
- | 1) rowspan="{{ $categorySpan }}" @endif>{{ $item->category ?: '—' }} |
+ 1) rowspan="{{ $categorySpan }}" @endif>
+
+ {{ (int) $item->type_sequence }}
+ {{ $item->category_display ?: '未分类' }}
+
+ |
@endif
@if ($sectionSpan !== 0)
1) rowspan="{{ $sectionSpan }}" @endif>
- {{ $sectionLabels[$loop->index] ?: '—' }}
+
+ {{ (int) $item->content_sequence }}
+ {{ $sectionLabels[$loop->index] ?: '未填写内容' }}
+
|
@endif
- {{ (int) ($item->category_sequence ?? 0) }} |
+ {{ (int) $item->item_sequence }} |
{{ $titleLabels[$loop->index] }} |
{{ $item->owner ?: '—' }} |
{{ $item->assignee ?: '—' }} |
diff --git a/tests/Feature/Business/SystemizationTodosTest.php b/tests/Feature/Business/SystemizationTodosTest.php
index 4336d67c..1fb00a51 100644
--- a/tests/Feature/Business/SystemizationTodosTest.php
+++ b/tests/Feature/Business/SystemizationTodosTest.php
@@ -28,7 +28,9 @@ class SystemizationTodosTest extends FeatureTestCase
->assertSeeText('从 Excel 同步待办')
->assertSeeText('总负责人')
->assertSeeText('责任人')
- ->assertSeeText('类型内序号')
+ ->assertSeeText('类型序号')
+ ->assertSeeText('内容序号')
+ ->assertSeeText('待办序号')
->assertSeeText('已逾期')
->assertSeeText('开始时间')
->assertSeeText('完成时间')
@@ -189,10 +191,11 @@ class SystemizationTodosTest extends FeatureTestCase
$pageResponse
->assertOk()
->assertSee('rowspan="2"', false)
- ->assertSee('内容 | 序号 | 待办事项 | ', false)
+ ->assertSee('内容 | 待办序号 | 待办事项 | ', false)
->assertSee('sys-todo-table__type', false)
- ->assertSee('title="当前类型内序号">1', false)
- ->assertSee('title="当前类型内序号">2', false)
+ ->assertSee('title="类型序号">1', false)
+ ->assertSee('title="内容序号">1', false)
+ ->assertSee('title="当前内容内待办序号">2', false)
->assertDontSee('1、平台收入核对')
->assertDontSee('3、退款核销')
->assertDontSee('1、收入事项 A')
@@ -206,17 +209,25 @@ class SystemizationTodosTest extends FeatureTestCase
);
$pageItems = $pageContent['items'];
$this->assertSame(
- [1, 2],
+ [1, 1],
$pageItems->where('category', '收入')
- ->pluck('category_sequence')
+ ->pluck('type_sequence')
->map(static fn (mixed $value): int => (int) $value)
->values()
->all(),
);
$this->assertSame(
- [1],
+ [1, 2],
+ $pageItems->where('category', '收入')
+ ->pluck('item_sequence')
+ ->map(static fn (mixed $value): int => (int) $value)
+ ->values()
+ ->all(),
+ );
+ $this->assertSame(
+ [2],
$pageItems->where('category', '成本')
- ->pluck('category_sequence')
+ ->pluck('type_sequence')
->map(static fn (mixed $value): int => (int) $value)
->values()
->all(),
@@ -226,11 +237,18 @@ class SystemizationTodosTest extends FeatureTestCase
'section' => 'other',
'page' => 'systemization-todos',
'category' => '收入',
- 'sequence' => 2,
+ 'item_sequence' => 2,
]))->assertOk();
$sequenceContent = $sequenceResponse->viewData('content');
$this->assertSame(['2、收入事项 B'], $sequenceContent['items']->pluck('title')->all());
+ $typeSequenceContent = $this->get(route('business.pages.show', [
+ 'section' => 'other',
+ 'page' => 'systemization-todos',
+ 'type_sequence' => 2,
+ ]))->assertOk()->viewData('content');
+ $this->assertSame(['3、成本事项'], $typeSequenceContent['items']->pluck('title')->all());
+
$highlightedTodo = $pageItems->firstWhere('title', '2、收入事项 B');
$this->get(route('business.pages.show', [
'section' => 'other',
@@ -285,6 +303,65 @@ class SystemizationTodosTest extends FeatureTestCase
);
}
+ public function test_same_content_stays_merged_and_receives_scoped_hierarchy_sequences(): void
+ {
+ $this->loginAs();
+
+ $pending = $this->createTodo('收入', '钉钉流程', '待办事项 A', '2026-10-30', 1);
+ $completed = $this->createTodo('收入', '钉钉流程', '待办事项 B', '2026-10-10', 2);
+ $completed->update([
+ 'status' => SystemizationTodo::STATUS_COMPLETED,
+ 'completed_at' => '2026-10-10',
+ ]);
+ $inProgress = $this->createTodo('收入', '钉钉流程', '待办事项 C', '2026-10-20', 3);
+ $inProgress->update(['status' => SystemizationTodo::STATUS_IN_PROGRESS]);
+ $secondContent = $this->createTodo('收入', '报销流程', '待办事项 D', '2026-11-01', 4);
+ $cost = $this->createTodo('成本', '成本核对', '待办事项 E', '2026-11-02', 5);
+
+ $response = $this->get(route('business.pages.show', [
+ 'section' => 'other',
+ 'page' => 'systemization-todos',
+ ]))->assertOk();
+ $content = $response->viewData('content');
+ $items = $content['items'];
+
+ $this->assertSame(
+ [$pending->id, $completed->id, $inProgress->id, $secondContent->id, $cost->id],
+ $items->pluck('id')->all(),
+ );
+ $this->assertSame(
+ [[1, 1, [1, 2, 3]], [1, 2, [1]], [2, 1, [1]]],
+ $items
+ ->groupBy(static fn (SystemizationTodo $todo): string => $todo->type_sequence . ':' . $todo->content_sequence)
+ ->map(static fn ($group): array => [
+ (int) $group->first()->type_sequence,
+ (int) $group->first()->content_sequence,
+ $group->pluck('item_sequence')->map(static fn (mixed $value): int => (int) $value)->all(),
+ ])
+ ->values()
+ ->all(),
+ );
+ $contentResponse = $this->get(route('business.pages.show', [
+ 'section' => 'other',
+ 'page' => 'systemization-todos',
+ 'type_sequence' => 1,
+ 'content_sequence' => 2,
+ ]))->assertOk();
+ $this->assertSame(
+ [$secondContent->id],
+ $contentResponse->viewData('content')['items']->pluck('id')->all(),
+ );
+ $this->assertSame(
+ [$completed->id, $inProgress->id],
+ $items
+ ->where('section', '钉钉流程')
+ ->whereIn('status', [SystemizationTodo::STATUS_COMPLETED, SystemizationTodo::STATUS_IN_PROGRESS])
+ ->pluck('id')
+ ->all(),
+ );
+ $this->assertSame(1, substr_count((string) $response->getContent(), '>钉钉流程<'));
+ }
+
public function test_page_renders_all_todos_on_one_continuous_list(): void
{
$this->loginAs();
From e2e31e3658098e4934ff1dd228ce5fecfbd69f78 Mon Sep 17 00:00:00 2001
From: qiuxiang <394405259@qq.com>
Date: Mon, 26 Oct 2026 14:44:53 +0800
Subject: [PATCH 7/7] =?UTF-8?q?=E5=AE=89=E8=A3=85=E8=B4=B9=E6=98=8E?=
=?UTF-8?q?=E7=BB=86=E5=A2=9E=E5=8A=A0=E5=B9=B3=E5=8F=B0=E7=AD=9B=E9=80=89?=
=?UTF-8?q?=E4=B8=8E=E5=B9=B3=E5=8F=B0=E6=B1=87=E6=80=BB=20(#595)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../InstallationDetailMirrorRepository.php | 24 ++++-
.../installation-fee-summary.blade.php | 93 ++++++++++++++++++-
tests/Feature/Business/ManagementCrudTest.php | 8 +-
...InstallationDetailMirrorRepositoryTest.php | 57 +++++++++++-
4 files changed, 171 insertions(+), 11 deletions(-)
diff --git a/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php b/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php
index 3b4d7b9a..2bba0cb2 100644
--- a/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php
+++ b/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php
@@ -128,7 +128,7 @@ class InstallationDetailMirrorRepository
/**
* 按客服审核时间汇总安装费;金额取安装明细子表的结算明细金额。
*
- * @return array{ready:bool,total:float,rows:list}
+ * @return array{ready:bool,total:float,rows:list}
*/
public function feeDetails(array $filters = []): array
{
@@ -160,27 +160,43 @@ class InstallationDetailMirrorRepository
->where('i.kfshTime', '>=', $from)
->where('i.kfshTime', '<', \Illuminate\Support\Carbon::parse($to)->addDay()->toDateString());
+ $platformSql = "CASE"
+ . " WHEN COALESCE(TRIM(install_main.shop_name), '') LIKE '%阿里巴巴%'"
+ . " OR COALESCE(TRIM(install_main.shop_name), '') LIKE '%天猫%'"
+ . " OR COALESCE(TRIM(install_main.shop_name), '') LIKE '%新佰广%'"
+ . " OR COALESCE(TRIM(install_main.shop_name), '') LIKE '%淘工厂%'"
+ . " OR COALESCE(TRIM(install_main.shop_name), '') LIKE '%淘宝%' THEN '淘系'"
+ . " WHEN COALESCE(TRIM(install_main.shop_name), '') LIKE '抖音-%' THEN '抖音'"
+ . " WHEN COALESCE(TRIM(install_main.shop_name), '') LIKE '拼多多-%' THEN '拼多多'"
+ . " WHEN COALESCE(TRIM(install_main.shop_name), '') LIKE '唯品会-%' THEN '唯品会'"
+ . " WHEN COALESCE(TRIM(install_main.shop_name), '') LIKE '京东-%' THEN '京东'"
+ . " WHEN COALESCE(TRIM(install_main.shop_name), '') LIKE '京喜-%' THEN '京喜'"
+ . " ELSE '其他' END";
+
$rows = $query
+ ->selectRaw($platformSql . ' AS platform')
->selectRaw("COALESCE(NULLIF(TRIM(install_main.shop_name), ''), '未填写店铺') AS store_name")
->selectRaw("COALESCE(NULLIF(TRIM(install_main.installCompanyName), ''), '未填写安装公司') AS installation_company")
->selectRaw("CASE WHEN install_main.costWay IS NULL OR TRIM(install_main.costWay) = '' THEN '包安装' ELSE '不包安装' END AS payment_channel")
->selectRaw('ROUND(SUM(CAST(i.settleItemAmount AS DECIMAL(18,2))), 2) AS amount')
->groupByRaw(
- "COALESCE(NULLIF(TRIM(install_main.shop_name), ''), '未填写店铺'),"
+ $platformSql . ','
+ . " COALESCE(NULLIF(TRIM(install_main.shop_name), ''), '未填写店铺'),"
. " COALESCE(NULLIF(TRIM(install_main.installCompanyName), ''), '未填写安装公司'),"
. " CASE WHEN install_main.costWay IS NULL OR TRIM(install_main.costWay) = '' THEN '包安装' ELSE '不包安装' END"
)
->havingRaw('ABS(SUM(CAST(i.settleItemAmount AS DECIMAL(18,2)))) > 0.004')
->get()
->map(static fn (object $row): array => [
+ 'platform' => trim((string) ($row->platform ?? '其他')),
'store_name' => trim((string) ($row->store_name ?? '')),
'installation_company' => trim((string) ($row->installation_company ?? '')),
'payment_channel' => (string) ($row->payment_channel ?? '包安装'),
'amount' => round((float) ($row->amount ?? 0), 2),
])
->sort(static fn (array $left, array $right): int =>
- [abs($right['amount']), $left['store_name'], $left['installation_company']]
- <=> [abs($left['amount']), $right['store_name'], $right['installation_company']]
+ [abs($right['amount']), $left['platform'], $left['store_name'], $left['installation_company']]
+ <=> [abs($left['amount']), $right['platform'], $right['store_name'], $right['installation_company']]
)
->values()
->all();
diff --git a/resources/views/business/pages/partials/installation-fee-summary.blade.php b/resources/views/business/pages/partials/installation-fee-summary.blade.php
index 9dcaecc6..583b3afe 100644
--- a/resources/views/business/pages/partials/installation-fee-summary.blade.php
+++ b/resources/views/business/pages/partials/installation-fee-summary.blade.php
@@ -164,7 +164,7 @@
.installation-fee-filters {
display: grid;
- grid-template-columns: repeat(3, minmax(10rem, 1fr));
+ grid-template-columns: repeat(4, minmax(8.5rem, 1fr));
gap: 0.75rem;
padding: 0.8rem 1.1rem;
border-bottom: 1px solid #eee8dd;
@@ -228,6 +228,42 @@
text-align: center;
}
+ .installation-fee-platform-summary {
+ margin: 0.75rem 0 1rem;
+ padding-top: 0.75rem;
+ border-top: 1px solid #d8d2c7;
+ }
+
+ .installation-fee-platform-summary h3 {
+ margin: 0 0 0.5rem;
+ color: #171717;
+ font-size: 0.75rem;
+ }
+
+ .installation-fee-platform-summary table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.75rem;
+ font-variant-numeric: tabular-nums;
+ }
+
+ .installation-fee-platform-summary th,
+ .installation-fee-platform-summary td {
+ padding: 0.5rem;
+ border-bottom: 1px solid #eee8dd;
+ text-align: left;
+ }
+
+ .installation-fee-platform-summary th {
+ background: #f7f0de;
+ color: #595959;
+ font-weight: 600;
+ }
+
+ .installation-fee-platform-summary .is-number {
+ text-align: right;
+ }
+
.installation-fee-dialog__foot {
padding: 0.65rem 1.1rem;
border-top: 1px solid #eee8dd;
@@ -235,6 +271,10 @@
font-size: 0.6875rem;
}
+ @media (max-width: 900px) {
+ .installation-fee-filters { grid-template-columns: repeat(2, minmax(10rem, 1fr)); }
+ }
+
@media (max-width: 720px) {
.installation-fee-filters { grid-template-columns: 1fr; }
.installation-fee-dialog__head { flex-direction: column; }
@@ -278,6 +318,12 @@
+
请选择客服审核日期后查看明细
+
|