diff --git a/modules/EcommerceFinance/Http/Controllers/AccountBills.php b/modules/EcommerceFinance/Http/Controllers/AccountBills.php index e6443489..839cfc7f 100644 --- a/modules/EcommerceFinance/Http/Controllers/AccountBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AccountBills.php @@ -144,6 +144,19 @@ class AccountBills extends Controller { $this->authorizeStoreReceivingBills(); + if ($bill_type === 'vipshop') { + $query = AccountBill::query() + ->where('company_id', company_id()) + ->where('bill_type', $bill_type); + + return response()->json([ + 'shops' => (clone $query)->whereNotNull('store_name')->where('store_name', '<>', '') + ->distinct()->orderBy('store_name')->pluck('store_name')->all(), + 'bill_items' => (clone $query)->whereNotNull('bill_item')->where('bill_item', '<>', '') + ->distinct()->orderBy('bill_item')->pluck('bill_item')->all(), + ]); + } + abort_unless(in_array($bill_type, ['douyin', 'pinduoduo', 'dewu', 'jd-transactions', 'jd-wallet'], true), 404, '该账户类型没有可用的筛选选项。'); $label = $this->mirrorLabel($bill_type); diff --git a/resources/assets/js/components/business/AccountBills.vue b/resources/assets/js/components/business/AccountBills.vue index 60ae74b1..ae2479a1 100644 --- a/resources/assets/js/components/business/AccountBills.vue +++ b/resources/assets/js/components/business/AccountBills.vue @@ -84,7 +84,7 @@ export default { // Every account-bill tab uses the same explicit picker path. Keeping one // control avoids browser-native date inputs appearing on older tabs. usesStableDateControl(){return true;}, - usesMirrorOptions(){return ['douyin','pinduoduo','dewu','jd-transactions','jd-wallet'].indexOf(this.billType)!==-1;}, + usesMirrorOptions(){return ['douyin','pinduoduo','dewu','jd-transactions','jd-wallet','vipshop'].indexOf(this.billType)!==-1;}, invalidDateRange(){return Boolean(this.filters.start&&this.filters.end&&this.filters.start>this.filters.end);}, startDateConfig(){return {...dateConfig,maxDate:this.filters.end||null};}, endDateConfig(){return {...dateConfig,minDate:this.filters.start||null};}, diff --git a/tests/Unit/View/AccountBillsTableTest.php b/tests/Unit/View/AccountBillsTableTest.php index 39a171d4..a1a0b771 100644 --- a/tests/Unit/View/AccountBillsTableTest.php +++ b/tests/Unit/View/AccountBillsTableTest.php @@ -11,10 +11,10 @@ class AccountBillsTableTest extends TestCase $source = file_get_contents(__DIR__ . '/../../../resources/assets/js/components/business/AccountBills.vue'); $this->assertStringContainsString("isDouyin(){return this.billType==='douyin';}", $source); - $this->assertStringContainsString("usesStableDateControl(){return ['pinduoduo','dewu'].indexOf(this.billType)!==-1;}", $source); + $this->assertStringContainsString("usesStableDateControl(){return true;}", $source); $this->assertStringContainsString("{ 'is-stable-date': usesStableDateControl }", $source); $this->assertStringContainsString('.tw-date.is-stable-date .tw-date-control', $source); - $this->assertStringContainsString("['douyin','pinduoduo','dewu','jd-transactions','jd-wallet'].indexOf(this.billType)!==-1", $source); + $this->assertStringContainsString("['douyin','pinduoduo','dewu','jd-transactions','jd-wallet','vipshop'].indexOf(this.billType)!==-1", $source); $this->assertStringContainsString('v-if="usesMirrorOptions && !filterOptionsError"', $source); $this->assertStringContainsString('page-key="store-receiving-bills"', $source); $this->assertStringContainsString(':tab-key="billType"', $source); diff --git a/tests/scripts/account-bills-filter-export.test.cjs b/tests/scripts/account-bills-filter-export.test.cjs index 9e5adf02..f72eba26 100644 --- a/tests/scripts/account-bills-filter-export.test.cjs +++ b/tests/scripts/account-bills-filter-export.test.cjs @@ -24,6 +24,46 @@ function component(get, billType = 'douyin') { const response = (total = 51) => ({ data: { data: [{ record_code: '1' }], total, current_page: 1, last_page: 3, per_page: 20 } }); +test('vipshop loads real options, queries selections and exports the same scope', async () => { + const requests = []; + const { instance, navigations } = component(async (url, request) => { + if (url.endsWith('/filter-options')) return { data: { shops: ['唯品会MP卓禾'], bill_items: ['销售货款'] } }; + requests.push(request.params); + return response(3); + }, 'vipshop'); + assert.equal(instance.usesMirrorOptions, true); + await instance.fetchFilterOptions(); + instance.filters.store_name = instance.filterOptions.shops[0]; + instance.filters.bill_item = instance.filterOptions.bill_items[0]; + instance.currentPage = 4; + await instance.applyFilters(); + assert.equal(requests[0].page, 1); + assert.equal(requests[0].store_name, '唯品会MP卓禾'); + assert.equal(requests[0].bill_item, '销售货款'); + instance.exportRows(); + const params = new URL(navigations[0], 'https://example.test').searchParams; + assert.equal(params.get('store_name'), requests[0].store_name); + assert.equal(params.get('bill_item'), requests[0].bill_item); + await instance.resetFilters(); + assert.equal(requests[1].store_name, ''); + assert.equal(requests[1].bill_item, ''); +}); + +test('vipshop option failure allows retry without reporting empty successful options', async () => { + let fail = true; + const { instance } = component(async () => { + if (fail) throw new Error('unavailable'); + return { data: { shops: ['唯品会MP卓禾'], bill_items: [] } }; + }, 'vipshop'); + await instance.fetchFilterOptions(); + assert.equal(instance.filterOptionsError, true); + assert.equal(instance.filterOptionsLoading, false); + fail = false; + await instance.fetchFilterOptions(); + assert.equal(instance.filterOptionsError, false); + assert.equal(instance.filterOptions.shops.length, 1); +}); + test('template compiles searchable filters and export scopes', () => { assert.deepEqual(compiler.compile(sfc.template.content).errors, []); }); diff --git a/tests/scripts/vipshop-filter-smoke.php b/tests/scripts/vipshop-filter-smoke.php new file mode 100644 index 00000000..4d5b78fa --- /dev/null +++ b/tests/scripts/vipshop-filter-smoke.php @@ -0,0 +1,43 @@ +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +config(['database.default' => 'filter_test', 'database.connections.filter_test' => [ + 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', +], 'model-caching.enabled' => false, 'cache.default' => 'array']); +$company = new App\Models\Common\Company; +$company->id = 1; +$app->instance(App\Models\Common\Company::class, $company); +(require '/workspace/modules/EcommerceFinance/Database/Migrations/2026_07_21_000002_create_ec_account_bills_table.php')->up(); +require '/workspace/modules/EcommerceFinance/Http/Controllers/AccountBills.php'; +$controller = new class extends Modules\EcommerceFinance\Http\Controllers\AccountBills { + protected function authorizeStoreReceivingBills(): void {} +}; +function check($condition, $message) { if (!$condition) throw new RuntimeException($message); } +foreach ([ + [1, 'vipshop', 'Store A', 'Sale', null], + [1, 'vipshop', 'Store A', 'Sale', null], + [1, 'vipshop', 'Store B', 'Refund', null], + [2, 'vipshop', 'Other company', 'Private item', null], + [1, 'douyin', 'Other platform', 'Other item', null], + [1, 'vipshop', 'Deleted store', 'Deleted item', '2026-09-01'], +] as $i => [$companyId, $type, $store, $item, $deleted]) { + Illuminate\Support\Facades\DB::table('ec_account_bills')->insert([ + 'company_id' => $companyId, 'bill_type' => $type, 'record_code' => (string) $i, + 'store_name' => $store, 'bill_item' => $item, 'deleted_at' => $deleted, + 'occurred_at' => '2026-09-22 12:00:00', 'income' => '12.34', 'expense' => '0', + ]); +} +$options = $controller->filterOptions('vipshop')->getData(true); +check($options['shops'] === ['Store A', 'Store B'], 'Shop options must be unique and company/platform/deletion scoped'); +check($options['bill_items'] === ['Refund', 'Sale'], 'Item options must be unique and scoped'); +$request = Illuminate\Http\Request::create('/', 'GET', ['store_name' => 'Store A', 'bill_item' => 'Sale', 'limit' => 1]); +$result = $controller->index($request, 'vipshop')->getData(true); +check($result['total'] === 2 && count($result['data']) === 1, 'Filtering and pagination failed'); +check(abs($result['summary']['income'] - 24.68) < 0.001, 'Summary must cover all matching rows'); +$export = $controller->export($request, 'vipshop'); +ob_start(); $export->sendContent(); $csv = ob_get_clean(); +check(substr_count($csv, 'Store A') === 2 && !str_contains($csv, 'Store B'), 'Export must contain all filtered rows only'); +echo "PASS: real SQLite query; scoped options; filtering; pagination; summary; full filtered export\n";