From 7175d3f73eb0fe0fb5a1bd10f5328aa5251afaaa Mon Sep 17 00:00:00 2001 From: qiuxiang <394405259@qq.com> Date: Tue, 6 Oct 2026 18:46:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BA=97=E9=93=BA=E6=94=B6=E6=AC=BE=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E8=B4=A6=E5=8D=95=EF=BC=9A=E6=89=80=E6=9C=89=E9=A1=B5?= =?UTF-8?q?=E7=AD=BE=E6=96=B0=E5=A2=9E=E3=80=8C=E6=80=BB=E8=B4=A6=E5=A4=87?= =?UTF-8?q?=E6=B3=A8=E3=80=8D=E7=AD=9B=E9=80=89=EF=BC=88=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E4=B8=8E=E5=AF=BC=E5=87=BA=E5=90=8C=E5=8F=A3=E5=BE=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AccountLedgerRemarkMap:新增 页签→平台→账单项目 的翻译(remarksForTab / billItemGroupsForTab / billItemsForTab);淘系支付宝、淘系微信同页签混有多个平台,按店铺名前缀再分平台 - LedgerRemarkFilter(新):把「总账备注」翻译成 SQL 条件,单平台页签 whereIn(账单项目),多平台页签 (店铺前缀 AND 账单项目 IN ...) OR ... - 控制器/仓库:AccountBills、AlipayBills、Pinduoduo/Dewu/TaobaoWechat 账户账单 + 对应仓库(含抖音权威、京东日账单、京东钱包、共享原始账单镜像)全部接入 ledger_remark 筛选;filter-options 统一返回 ledger_remarks 供下拉 - 前端:AccountBills.vue / TaobaoAlipayBills.vue 在「店铺」右侧新增「总账备注」下拉,参与查询、重置与导出 - 测试:新增映射翻译、SQL 条件、唯品会JX 页面+导出筛选用例;同步 filter-options 断言 --- .../Http/Concerns/DecoratesLedgerRemarks.php | 37 +++++ .../Http/Controllers/AccountBills.php | 58 ++++---- .../Http/Controllers/AlipayBills.php | 23 ++- .../Http/Controllers/DewuAccountBills.php | 8 +- .../Controllers/PinduoduoAccountBills.php | 8 +- .../Controllers/TaobaoWechatAccountBills.php | 8 +- .../DewuSettlementBillRepository.php | 22 +++ .../DouyinAuthoritativeBillRepository.php | 2 + .../Repositories/JdDailyBillRepository.php | 2 + .../PinduoduoSettlementBillRepository.php | 22 +++ ...oreReceivingAlipayBillMirrorRepository.php | 22 +++ .../StoreReceivingJdWalletBillRepository.php | 2 + .../TaobaoAlipaySettlementBillRepository.php | 14 ++ .../TaobaoWechatSettlementBillRepository.php | 22 +++ .../Services/AccountLedgerRemarkMap.php | 136 ++++++++++++++++++ .../Services/LedgerRemarkFilter.php | 106 ++++++++++++++ .../js/components/business/AccountBills.vue | 12 +- .../components/business/TaobaoAlipayBills.vue | 12 +- .../Business/DewuReceivingBillsTest.php | 1 + tests/Feature/Business/PagesTest.php | 6 +- .../Business/PinduoduoReceivingBillsTest.php | 1 + .../TaobaoWechatReceivingBillsTest.php | 1 + .../Business/VipshopJxReceivingBillsTest.php | 45 ++++++ .../AccountLedgerRemarkMapTest.php | 44 ++++++ .../LedgerRemarkFilterTest.php | 62 ++++++++ 25 files changed, 624 insertions(+), 52 deletions(-) create mode 100644 modules/EcommerceFinance/Services/LedgerRemarkFilter.php create mode 100644 tests/Unit/EcommerceFinance/LedgerRemarkFilterTest.php diff --git a/modules/EcommerceFinance/Http/Concerns/DecoratesLedgerRemarks.php b/modules/EcommerceFinance/Http/Concerns/DecoratesLedgerRemarks.php index 4d23dbdf..6e8cfd90 100644 --- a/modules/EcommerceFinance/Http/Concerns/DecoratesLedgerRemarks.php +++ b/modules/EcommerceFinance/Http/Concerns/DecoratesLedgerRemarks.php @@ -11,11 +11,48 @@ use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap; */ trait DecoratesLedgerRemarks { + /** 「总账备注」在本页签没有对应账单项目时的占位条件(保证命中 0 行)。 */ + private const LEDGER_REMARK_NO_MATCH_ITEM = '__no_such_ledger_remark_item__'; + protected function ledgerRemarkMap(): AccountLedgerRemarkMap { return app(AccountLedgerRemarkMap::class); } + /** + * 把「总账备注」筛选翻译成账单项目集合。 + * + * 页面、列设置与导出都调用这里,保证同一份筛选口径; + * bill_items 供各仓库 whereIn(账单项目) 使用,bill_item_groups 供 + * 淘系这种同一页签混多平台的仓库按店铺名前缀再细分。 + * + * @param array $filters + * @return array + */ + protected function withLedgerRemarkFilter(array $filters, string $tab): array + { + $remark = trim((string) ($filters['ledger_remark'] ?? '')); + if ($remark === '') { + return $filters; + } + + $groups = $this->ledgerRemarkMap()->billItemGroupsForTab($tab, $remark); + $filters['bill_item_groups'] = $groups; + + $items = []; + foreach ($groups as $group) { + foreach ($group['items'] as $item) { + $items[$item] = true; + } + } + $filters['bill_items'] = array_keys($items); + if ($filters['bill_items'] === []) { + $filters['bill_items'] = [self::LEDGER_REMARK_NO_MATCH_ITEM]; + } + + return $filters; + } + /** * 分页结果(含 data 行数组)补总账备注。 * diff --git a/modules/EcommerceFinance/Http/Controllers/AccountBills.php b/modules/EcommerceFinance/Http/Controllers/AccountBills.php index d5088720..349bdd6f 100644 --- a/modules/EcommerceFinance/Http/Controllers/AccountBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AccountBills.php @@ -45,6 +45,7 @@ class AccountBills extends Controller $request->validate([ 'store_name' => ['nullable', 'string', 'max:255'], + 'ledger_remark' => ['nullable', 'string', 'max:191'], 'bill_item' => ['nullable', 'string', 'max:255'], 'start' => ['nullable', 'date_format:Y-m-d'], 'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'], @@ -54,35 +55,8 @@ class AccountBills extends Controller 'columns.*' => ['string', 'max:128'], ]); - $query = AccountBill::query() - ->where('company_id', company_id()) - ->where('bill_type', $billType); - - if ($store = $request->get('store_name')) { - $query->where('store_name', $store); - } - - if ($item = $request->get('bill_item')) { - $query->where('bill_item', $item); - } - - if ($start = $request->get('start')) { - $query->where('occurred_at', '>=', $start); - } - - if ($end = $request->get('end')) { - $query->where('occurred_at', '<=', $end . ' 23:59:59'); - } - - if ($keyword = $request->get('keyword')) { - $query->where(function ($q) use ($keyword) { - $like = '%' . $keyword . '%'; - $q->where('record_code', 'like', $like) - ->orWhere('order_no', 'like', $like) - ->orWhere('remark', 'like', $like) - ->orWhere('bill_item', 'like', $like); - }); - } + // 与导出共用同一份筛选(含「总账备注」翻译出的账单项目集合)。 + $query = $this->filteredQuery($request, $billType); $sortable = ['occurred_at', 'income', 'expense', 'balance', 'record_code']; $sort = in_array($request->get('sort'), $sortable, true) ? $request->get('sort') : 'occurred_at'; @@ -140,6 +114,7 @@ class AccountBills extends Controller $filters = $request->validate([ 'store_name' => ['nullable', 'string', 'max:255'], + 'ledger_remark' => ['nullable', 'string', 'max:191'], 'bill_item' => ['nullable', 'string', 'max:255'], 'start' => ['nullable', 'date_format:Y-m-d'], 'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'], @@ -158,6 +133,8 @@ class AccountBills extends Controller { $this->authorizeStoreReceivingBills(); + $ledgerRemarks = $this->ledgerRemarkMap()->remarksForTab($bill_type); + if (in_array($bill_type, ['vipshop', 'vipshop-jx'], true)) { $query = AccountBill::query() ->where('company_id', company_id()) @@ -168,6 +145,7 @@ class AccountBills extends Controller ->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(), + 'ledger_remarks' => $ledgerRemarks, ]); } @@ -177,7 +155,7 @@ class AccountBills extends Controller try { return response()->json( - $this->mirrorRepository($bill_type)->filterOptions() + $this->mirrorRepository($bill_type)->filterOptions() + ['ledger_remarks' => $ledgerRemarks] ); } catch (RuntimeException $exception) { if (! in_array($exception->getMessage(), [ @@ -195,6 +173,7 @@ class AccountBills extends Controller : $label . '账户账单共享业务镜像正在首次同步,筛选选项稍后可用。', 'shops' => [], 'bill_items' => [], + 'ledger_remarks' => $ledgerRemarks, ], 503); } } @@ -283,6 +262,14 @@ class AccountBills extends Controller $query->where('store_name', $store); } + $ledgerFilters = $this->withLedgerRemarkFilter( + ['ledger_remark' => $request->get('ledger_remark')], + $billType, + ); + if ($ledgerFilters['bill_items'] !== []) { + $query->whereIn('bill_item', $ledgerFilters['bill_items']); + } + if ($item = $request->get('bill_item')) { $query->where('bill_item', $item); } @@ -310,7 +297,7 @@ class AccountBills extends Controller private function mirrorIndex(Request $request, string $billType) { - $filters = $this->mirrorFilters($request); + $filters = $this->mirrorFilters($request, $billType); $sort = $this->sort($request); $direction = $this->direction($request); $page = max((int) $request->get('page', 1), 1); @@ -349,7 +336,7 @@ class AccountBills extends Controller private function exportMirror(Request $request, string $billType) { - $filters = $this->mirrorFilters($request); + $filters = $this->mirrorFilters($request, $billType); $definition = AccountBillColumns::for($billType); $columns = collect($definition['columns'] ?? []); $requested = $request->query('columns', []); @@ -404,10 +391,11 @@ class AccountBills extends Controller } /** @return array */ - private function mirrorFilters(Request $request): array + private function mirrorFilters(Request $request, string $billType): array { $filters = $request->validate([ 'store_name' => ['nullable', 'string', 'max:255'], + 'ledger_remark' => ['nullable', 'string', 'max:191'], 'bill_item' => ['nullable', 'string', 'max:255'], 'start' => ['nullable', 'date_format:Y-m-d'], 'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'], @@ -415,7 +403,9 @@ class AccountBills extends Controller 'keyword_match' => ['nullable', 'in:contains,exact-record'], ]); - return array_map(static fn (mixed $value): string => trim((string) $value), $filters); + $filters = array_map(static fn (mixed $value): string => trim((string) $value), $filters); + + return $this->withLedgerRemarkFilter($filters, $billType); } private function mirrorRepository(string $billType): object diff --git a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php index c5eeffdc..258c422d 100644 --- a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php @@ -55,6 +55,7 @@ class AlipayBills extends Controller $dir = $request->get('dir') === 'asc' ? 'asc' : 'desc'; $filters = $request->validate([ 'store_name' => ['nullable', 'string', 'max:255'], + 'ledger_remark' => ['nullable', 'string', 'max:191'], 'source_channel' => ['nullable', 'string', 'max:128'], 'bill_item' => ['nullable', 'string', 'max:255'], 'business_type' => ['nullable', 'string', 'max:255'], @@ -62,11 +63,15 @@ class AlipayBills extends Controller 'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'], 'keyword' => ['nullable', 'string', 'max:255'], ]); + $filters = $this->withLedgerRemarkFilter( + array_map(static fn (mixed $value): string => trim((string) $value), $filters), + 'taobao-alipay', + ); try { return response()->json($this->withLedgerRemarks( $repository->paginate( - array_map(static fn (mixed $value): string => trim((string) $value), $filters), + $filters, $sort, $dir, max((int) $request->get('page', 1), 1), @@ -95,10 +100,12 @@ class AlipayBills extends Controller { $this->authorizeStoreReceivingBills(); + $ledgerRemarks = $this->ledgerRemarkMap()->remarksForTab('taobao-alipay'); + try { return response()->json($repository->filterOptionsForChannel( 'alipayCSV', StoreReceivingAlipayBillMirrorRepository::DATASET, true - )); + ) + ['ledger_remarks' => $ledgerRemarks]); } catch (RuntimeException $exception) { if (! in_array($exception->getMessage(), [ 'store_receiving_alipay_bill_mirror_not_ready', @@ -113,6 +120,7 @@ class AlipayBills extends Controller : '淘系支付宝账单共享业务镜像正在首次同步,筛选选项稍后可用。', 'shops' => [], 'bill_items' => [], + 'ledger_remarks' => $ledgerRemarks, ], 503); } } @@ -122,6 +130,7 @@ class AlipayBills extends Controller $this->authorizeStoreReceivingBills(); $filters = $request->validate([ 'store_name' => ['nullable', 'string', 'max:255'], + 'ledger_remark' => ['nullable', 'string', 'max:191'], 'source_channel' => ['nullable', 'string', 'max:128'], 'bill_item' => ['nullable', 'string', 'max:255'], 'business_type' => ['nullable', 'string', 'max:255'], @@ -134,16 +143,16 @@ class AlipayBills extends Controller $columns = $this->requestedExportColumns($filters['columns'] ?? []); unset($filters['columns']); + $filters = $this->withLedgerRemarkFilter( + array_map(static fn (mixed $value): string => trim((string) $value), $filters), + 'taobao-alipay', + ); try { $sortable = ['record_code', 'occurred_at', 'income', 'expense', 'balance']; $sort = in_array($request->get('sort'), $sortable, true) ? $request->get('sort') : 'occurred_at'; $dir = $request->get('dir') === 'asc' ? 'asc' : 'desc'; - $rows = $repository->exportRows( - array_map(static fn (mixed $value): string => trim((string) $value), $filters), - $sort, - $dir, - ); + $rows = $repository->exportRows($filters, $sort, $dir); } catch (RuntimeException $exception) { if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') { throw $exception; diff --git a/modules/EcommerceFinance/Http/Controllers/DewuAccountBills.php b/modules/EcommerceFinance/Http/Controllers/DewuAccountBills.php index 732ae137..625f2145 100644 --- a/modules/EcommerceFinance/Http/Controllers/DewuAccountBills.php +++ b/modules/EcommerceFinance/Http/Controllers/DewuAccountBills.php @@ -28,6 +28,7 @@ class DewuAccountBills extends Controller 'limit' => ['nullable', 'integer', 'min:1', 'max:100'], ]); $filters = collect($validated)->only(array_keys($this->filterRules()))->all(); + $filters = $this->withLedgerRemarkFilter($filters, 'dewu'); try { $data = $repository->paginateAccountBills( @@ -63,8 +64,10 @@ class DewuAccountBills extends Controller { $this->authorizeStoreReceivingBills(); + $ledgerRemarks = $this->ledgerRemarkMap()->remarksForTab('dewu'); + try { - return response()->json($repository->accountBillFilterOptions()); + return response()->json($repository->accountBillFilterOptions() + ['ledger_remarks' => $ledgerRemarks]); } catch (RuntimeException $exception) { if ($exception->getMessage() !== 'dewu_authoritative_bills_not_ready') { throw $exception; @@ -74,6 +77,7 @@ class DewuAccountBills extends Controller 'message' => '得物完整账单尚未完成首次导入。', 'shops' => [], 'bill_items' => [], + 'ledger_remarks' => $ledgerRemarks, ], 503); } } @@ -92,6 +96,7 @@ class DewuAccountBills extends Controller 'dir' => ['nullable', 'in:asc,desc'], ]); $filters = collect($validated)->only(array_keys($this->filterRules()))->all(); + $filters = $this->withLedgerRemarkFilter($filters, 'dewu'); $definition = AccountBillColumns::for('dewu'); $available = collect($definition['columns'] ?? [])->keyBy('field'); $requested = $request->query('columns', []); @@ -148,6 +153,7 @@ class DewuAccountBills extends Controller { return [ 'store_name' => ['nullable', 'string', 'max:255'], + 'ledger_remark' => ['nullable', 'string', 'max:191'], 'bill_item' => ['nullable', 'string', 'max:255'], 'start' => ['nullable', 'date_format:Y-m-d'], 'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'], diff --git a/modules/EcommerceFinance/Http/Controllers/PinduoduoAccountBills.php b/modules/EcommerceFinance/Http/Controllers/PinduoduoAccountBills.php index 79ba5a1d..c0324508 100644 --- a/modules/EcommerceFinance/Http/Controllers/PinduoduoAccountBills.php +++ b/modules/EcommerceFinance/Http/Controllers/PinduoduoAccountBills.php @@ -28,6 +28,7 @@ class PinduoduoAccountBills extends Controller 'limit' => ['nullable', 'integer', 'min:1', 'max:100'], ]); $filters = collect($validated)->only(array_keys($this->filterRules()))->all(); + $filters = $this->withLedgerRemarkFilter($filters, 'pinduoduo'); try { $data = $repository->paginateAccountBills( @@ -63,8 +64,10 @@ class PinduoduoAccountBills extends Controller { $this->authorizeStoreReceivingBills(); + $ledgerRemarks = $this->ledgerRemarkMap()->remarksForTab('pinduoduo'); + try { - return response()->json($repository->accountBillFilterOptions()); + return response()->json($repository->accountBillFilterOptions() + ['ledger_remarks' => $ledgerRemarks]); } catch (RuntimeException $exception) { if ($exception->getMessage() !== 'pinduoduo_authoritative_bills_not_ready') { throw $exception; @@ -74,6 +77,7 @@ class PinduoduoAccountBills extends Controller 'message' => '拼多多完整货款账单尚未完成首次导入。', 'shops' => [], 'bill_items' => [], + 'ledger_remarks' => $ledgerRemarks, ], 503); } } @@ -92,6 +96,7 @@ class PinduoduoAccountBills extends Controller 'dir' => ['nullable', 'in:asc,desc'], ]); $filters = collect($validated)->only(array_keys($this->filterRules()))->all(); + $filters = $this->withLedgerRemarkFilter($filters, 'pinduoduo'); $definition = AccountBillColumns::for('pinduoduo'); $available = collect($definition['columns'] ?? [])->keyBy('field'); $requested = $request->query('columns', []); @@ -148,6 +153,7 @@ class PinduoduoAccountBills extends Controller { return [ 'store_name' => ['nullable', 'string', 'max:255'], + 'ledger_remark' => ['nullable', 'string', 'max:191'], 'bill_item' => ['nullable', 'string', 'max:255'], 'start' => ['nullable', 'date_format:Y-m-d'], 'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'], diff --git a/modules/EcommerceFinance/Http/Controllers/TaobaoWechatAccountBills.php b/modules/EcommerceFinance/Http/Controllers/TaobaoWechatAccountBills.php index e1b491f6..a99ea17f 100644 --- a/modules/EcommerceFinance/Http/Controllers/TaobaoWechatAccountBills.php +++ b/modules/EcommerceFinance/Http/Controllers/TaobaoWechatAccountBills.php @@ -28,6 +28,7 @@ class TaobaoWechatAccountBills extends Controller 'limit' => ['nullable', 'integer', 'min:1', 'max:100'], ]); $filters = collect($validated)->only(array_keys($this->filterRules()))->all(); + $filters = $this->withLedgerRemarkFilter($filters, 'taobao-wechat'); try { $data = $repository->paginateAccountBills( @@ -63,8 +64,10 @@ class TaobaoWechatAccountBills extends Controller { $this->authorizeStoreReceivingBills(); + $ledgerRemarks = $this->ledgerRemarkMap()->remarksForTab('taobao-wechat'); + try { - return response()->json($repository->accountBillFilterOptions()); + return response()->json($repository->accountBillFilterOptions() + ['ledger_remarks' => $ledgerRemarks]); } catch (RuntimeException $exception) { if ($exception->getMessage() !== 'taobao_wechat_authoritative_bills_not_ready') { throw $exception; @@ -74,6 +77,7 @@ class TaobaoWechatAccountBills extends Controller 'message' => '淘系微信完整账单尚未完成首次导入。', 'shops' => [], 'bill_items' => [], + 'ledger_remarks' => $ledgerRemarks, ], 503); } } @@ -92,6 +96,7 @@ class TaobaoWechatAccountBills extends Controller 'dir' => ['nullable', 'in:asc,desc'], ]); $filters = collect($validated)->only(array_keys($this->filterRules()))->all(); + $filters = $this->withLedgerRemarkFilter($filters, 'taobao-wechat'); $definition = AccountBillColumns::for('taobao-wechat'); $available = collect($definition['columns'] ?? [])->keyBy('field'); $requested = $request->query('columns', []); @@ -148,6 +153,7 @@ class TaobaoWechatAccountBills extends Controller { return [ 'store_name' => ['nullable', 'string', 'max:255'], + 'ledger_remark' => ['nullable', 'string', 'max:191'], 'bill_item' => ['nullable', 'string', 'max:255'], 'start' => ['nullable', 'date_format:Y-m-d'], 'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'], diff --git a/modules/EcommerceFinance/Repositories/DewuSettlementBillRepository.php b/modules/EcommerceFinance/Repositories/DewuSettlementBillRepository.php index e2105465..25608e0e 100644 --- a/modules/EcommerceFinance/Repositories/DewuSettlementBillRepository.php +++ b/modules/EcommerceFinance/Repositories/DewuSettlementBillRepository.php @@ -7,6 +7,7 @@ use Illuminate\Database\ConnectionInterface; use Illuminate\Database\DatabaseManager; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\Schema; +use Modules\EcommerceFinance\Services\LedgerRemarkFilter; use RuntimeException; class DewuSettlementBillRepository @@ -224,6 +225,8 @@ class DewuSettlementBillRepository $query->where('bill_item', $billItem); } + $this->applyLedgerRemarkFilter($query, $filters); + $from = $this->date(isset($filters['start']) ? (string) $filters['start'] : null); if ($from !== null) { $query->where('bill_date', '>=', $from->format('Y-m-d') . ' 00:00:00'); @@ -250,6 +253,25 @@ class DewuSettlementBillRepository } } + /** 「总账备注」筛选:翻译成账单项目集合(得物单平台)。 */ + private function applyLedgerRemarkFilter(Builder $query, array $filters): void + { + $groups = is_array($filters['bill_item_groups'] ?? null) ? $filters['bill_item_groups'] : []; + if ($groups !== []) { + LedgerRemarkFilter::applyGroups( + $query, + $groups, + 'bill_item', + static fn (Builder $sub, string $prefix) => $sub->where('store_name', 'like', $prefix . '%'), + static fn (Builder $sub, string $prefix) => $sub->where('store_name', 'not like', $prefix . '%'), + ); + + return; + } + + LedgerRemarkFilter::itemsWhereIn($query, (array) ($filters['bill_items'] ?? []), 'bill_item'); + } + private function accountBillSortColumn(string $sort): string { return [ diff --git a/modules/EcommerceFinance/Repositories/DouyinAuthoritativeBillRepository.php b/modules/EcommerceFinance/Repositories/DouyinAuthoritativeBillRepository.php index fda0ef3d..f141b144 100644 --- a/modules/EcommerceFinance/Repositories/DouyinAuthoritativeBillRepository.php +++ b/modules/EcommerceFinance/Repositories/DouyinAuthoritativeBillRepository.php @@ -5,6 +5,7 @@ namespace Modules\EcommerceFinance\Repositories; use Illuminate\Database\DatabaseManager; use Illuminate\Database\Query\Builder; use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry; +use Modules\EcommerceFinance\Services\LedgerRemarkFilter; use RuntimeException; class DouyinAuthoritativeBillRepository @@ -121,6 +122,7 @@ class DouyinAuthoritativeBillRepository } } if (($filters['bill_item'] ?? '') !== '') $query->where($item, 'like', '%' . $this->escape($filters['bill_item']) . '%'); + LedgerRemarkFilter::itemsWhereIn($query, (array) ($filters['bill_items'] ?? []), $item); if (($filters['start'] ?? '') !== '') $query->where($date, '>=', $filters['start'] . ' 00:00:00'); if (($filters['end'] ?? '') !== '') $query->where($date, '<', date('Y-m-d', strtotime($filters['end'] . ' +1 day')) . ' 00:00:00'); if (($filters['keyword'] ?? '') !== '') { diff --git a/modules/EcommerceFinance/Repositories/JdDailyBillRepository.php b/modules/EcommerceFinance/Repositories/JdDailyBillRepository.php index cabb79fa..37f4a8ee 100644 --- a/modules/EcommerceFinance/Repositories/JdDailyBillRepository.php +++ b/modules/EcommerceFinance/Repositories/JdDailyBillRepository.php @@ -5,6 +5,7 @@ namespace Modules\EcommerceFinance\Repositories; use Illuminate\Database\DatabaseManager; use Illuminate\Database\Query\Builder; use Modules\EcommerceFinance\JdBillPlatform; +use Modules\EcommerceFinance\Services\LedgerRemarkFilter; use RuntimeException; class JdDailyBillRepository @@ -85,6 +86,7 @@ class JdDailyBillRepository foreach (['store_name', 'bill_item'] as $field) { if (($filters[$field] ?? '') !== '') $q->where($field, $filters[$field]); } + LedgerRemarkFilter::itemsWhereIn($q, (array) ($filters['bill_items'] ?? []), 'bill_item'); if (($filters['start'] ?? '') !== '') $q->where('occurred_at', '>=', $filters['start'] . ' 00:00:00'); if (($filters['end'] ?? '') !== '') $q->where('occurred_at', '<', date('Y-m-d', strtotime($filters['end'] . ' +1 day')) . ' 00:00:00'); if (($filters['keyword'] ?? '') !== '') { diff --git a/modules/EcommerceFinance/Repositories/PinduoduoSettlementBillRepository.php b/modules/EcommerceFinance/Repositories/PinduoduoSettlementBillRepository.php index b3922e20..55ad3aa5 100644 --- a/modules/EcommerceFinance/Repositories/PinduoduoSettlementBillRepository.php +++ b/modules/EcommerceFinance/Repositories/PinduoduoSettlementBillRepository.php @@ -7,6 +7,7 @@ use Illuminate\Database\ConnectionInterface; use Illuminate\Database\DatabaseManager; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\Schema; +use Modules\EcommerceFinance\Services\LedgerRemarkFilter; use RuntimeException; class PinduoduoSettlementBillRepository @@ -224,6 +225,8 @@ class PinduoduoSettlementBillRepository $query->where('bill_item', $billItem); } + $this->applyLedgerRemarkFilter($query, $filters); + $from = $this->date(isset($filters['start']) ? (string) $filters['start'] : null); if ($from !== null) { $query->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00'); @@ -249,6 +252,25 @@ class PinduoduoSettlementBillRepository } } + /** 「总账备注」筛选:翻译成账单项目集合(拼多多单平台)。 */ + private function applyLedgerRemarkFilter(Builder $query, array $filters): void + { + $groups = is_array($filters['bill_item_groups'] ?? null) ? $filters['bill_item_groups'] : []; + if ($groups !== []) { + LedgerRemarkFilter::applyGroups( + $query, + $groups, + 'bill_item', + static fn (Builder $sub, string $prefix) => $sub->where('store_name', 'like', $prefix . '%'), + static fn (Builder $sub, string $prefix) => $sub->where('store_name', 'not like', $prefix . '%'), + ); + + return; + } + + LedgerRemarkFilter::itemsWhereIn($query, (array) ($filters['bill_items'] ?? []), 'bill_item'); + } + private function accountBillSortColumn(string $sort): string { return [ diff --git a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php index 1d5a6943..59e99cd8 100644 --- a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry; +use Modules\EcommerceFinance\Services\LedgerRemarkFilter; use RuntimeException; /** 店铺收款账单·淘系支付宝账户只读经营分析副本镜像。 */ @@ -446,6 +447,19 @@ class StoreReceivingAlipayBillMirrorRepository if (($filters['bill_item'] ?? '') !== '') { $query->where('b.fee_name', 'like', '%' . $this->escapeLike((string) $filters['bill_item']) . '%'); } + // 「总账备注」:共享原始账单镜像只有 shop_id,平台归属用店铺资料表的店铺名前缀判定。 + $groups = is_array($filters['bill_item_groups'] ?? null) ? $filters['bill_item_groups'] : []; + if ($groups !== []) { + LedgerRemarkFilter::applyGroups( + $query, + $groups, + 'b.fee_name', + fn (Builder $sub, string $prefix) => $sub->whereIn('b.shop_id', $this->shopIdsLike($prefix)), + fn (Builder $sub, string $prefix) => $sub->whereNotIn('b.shop_id', $this->shopIdsLike($prefix)), + ); + } else { + LedgerRemarkFilter::itemsWhereIn($query, (array) ($filters['bill_items'] ?? []), 'b.fee_name'); + } if (($filters['business_type'] ?? '') !== '') { $query->where('b.inout_type', $this->directionCode((string) $filters['business_type'])); } @@ -507,6 +521,14 @@ class StoreReceivingAlipayBillMirrorRepository $query->whereNotIn('b.shop_id', $excludedShopIds); } + /** 店铺名前缀 → shop_id 子查询(共享原始账单镜像按店铺资料表判定平台)。 */ + private function shopIdsLike(string $prefix): Builder + { + return $this->mirror->connection()->table('ops_jst_shop_info') + ->where('shop_name', 'like', $this->escapeLike($prefix) . '%') + ->select('shop_id'); + } + /** @param array> $fixedFilters */ private function channelCoversDataset(string|array|null $channel, array $fixedFilters): bool { diff --git a/modules/EcommerceFinance/Repositories/StoreReceivingJdWalletBillRepository.php b/modules/EcommerceFinance/Repositories/StoreReceivingJdWalletBillRepository.php index 53c31705..197823b2 100644 --- a/modules/EcommerceFinance/Repositories/StoreReceivingJdWalletBillRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreReceivingJdWalletBillRepository.php @@ -4,6 +4,7 @@ namespace Modules\EcommerceFinance\Repositories; use Illuminate\Database\DatabaseManager; use Illuminate\Database\Query\Builder; +use Modules\EcommerceFinance\Services\LedgerRemarkFilter; use RuntimeException; class StoreReceivingJdWalletBillRepository @@ -72,6 +73,7 @@ class StoreReceivingJdWalletBillRepository foreach (['store_name', 'bill_item'] as $field) { if (($filters[$field] ?? '') !== '') $q->where($field, $filters[$field]); } + LedgerRemarkFilter::itemsWhereIn($q, (array) ($filters['bill_items'] ?? []), 'bill_item'); if (($filters['start'] ?? '') !== '') $q->where('occurred_at', '>=', $filters['start'] . ' 00:00:00'); if (($filters['end'] ?? '') !== '') $q->where('occurred_at', '<', date('Y-m-d', strtotime($filters['end'] . ' +1 day')) . ' 00:00:00'); if (($filters['keyword'] ?? '') !== '') { diff --git a/modules/EcommerceFinance/Repositories/TaobaoAlipaySettlementBillRepository.php b/modules/EcommerceFinance/Repositories/TaobaoAlipaySettlementBillRepository.php index ed2c3345..11d04427 100644 --- a/modules/EcommerceFinance/Repositories/TaobaoAlipaySettlementBillRepository.php +++ b/modules/EcommerceFinance/Repositories/TaobaoAlipaySettlementBillRepository.php @@ -7,6 +7,7 @@ use Illuminate\Database\ConnectionInterface; use Illuminate\Database\DatabaseManager; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\Schema; +use Modules\EcommerceFinance\Services\LedgerRemarkFilter; use RuntimeException; class TaobaoAlipaySettlementBillRepository @@ -119,6 +120,19 @@ class TaobaoAlipaySettlementBillRepository if ($channel !== '') $query->where('business_bill_source', 'like', '%' . $this->escapeLike($channel) . '%'); $item = trim((string) ($filters['bill_item'] ?? '')); if ($item !== '') $query->where('bill_item', $item); + // 「总账备注」:淘系支付宝页签同页混有天猫/淘宝/阿里巴巴店铺,按店铺名前缀分平台过滤。 + $groups = is_array($filters['bill_item_groups'] ?? null) ? $filters['bill_item_groups'] : []; + if ($groups !== []) { + LedgerRemarkFilter::applyGroups( + $query, + $groups, + 'bill_item', + static fn (Builder $sub, string $prefix) => $sub->where('store_name', 'like', $prefix . '%'), + static fn (Builder $sub, string $prefix) => $sub->where('store_name', 'not like', $prefix . '%'), + ); + } else { + LedgerRemarkFilter::itemsWhereIn($query, (array) ($filters['bill_items'] ?? []), 'bill_item'); + } $type = trim((string) ($filters['business_type'] ?? '')); if ($type === '收入') $query->where('income_amount', '>', 0); if ($type === '支出') $query->where('expense_amount', '>', 0); diff --git a/modules/EcommerceFinance/Repositories/TaobaoWechatSettlementBillRepository.php b/modules/EcommerceFinance/Repositories/TaobaoWechatSettlementBillRepository.php index 2d79c4a9..f93cb2be 100644 --- a/modules/EcommerceFinance/Repositories/TaobaoWechatSettlementBillRepository.php +++ b/modules/EcommerceFinance/Repositories/TaobaoWechatSettlementBillRepository.php @@ -7,6 +7,7 @@ use Illuminate\Database\ConnectionInterface; use Illuminate\Database\DatabaseManager; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\Schema; +use Modules\EcommerceFinance\Services\LedgerRemarkFilter; use RuntimeException; class TaobaoWechatSettlementBillRepository @@ -224,6 +225,8 @@ class TaobaoWechatSettlementBillRepository $query->where('bill_item', $billItem); } + $this->applyLedgerRemarkFilter($query, $filters); + $from = $this->date(isset($filters['start']) ? (string) $filters['start'] : null); if ($from !== null) { $query->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00'); @@ -250,6 +253,25 @@ class TaobaoWechatSettlementBillRepository } } + /** 「总账备注」筛选:淘系微信页签同页混有天猫/淘宝店铺,按店铺名前缀分平台过滤。 */ + private function applyLedgerRemarkFilter(Builder $query, array $filters): void + { + $groups = is_array($filters['bill_item_groups'] ?? null) ? $filters['bill_item_groups'] : []; + if ($groups !== []) { + LedgerRemarkFilter::applyGroups( + $query, + $groups, + 'bill_item', + static fn (Builder $sub, string $prefix) => $sub->where('store_name', 'like', $prefix . '%'), + static fn (Builder $sub, string $prefix) => $sub->where('store_name', 'not like', $prefix . '%'), + ); + + return; + } + + LedgerRemarkFilter::itemsWhereIn($query, (array) ($filters['bill_items'] ?? []), 'bill_item'); + } + private function accountBillSortColumn(string $sort): string { return [ diff --git a/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php b/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php index 45f59774..e5bf0197 100644 --- a/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php +++ b/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php @@ -31,6 +31,25 @@ class AccountLedgerRemarkMap 'vipshop-jx' => '唯品会jx', ]; + /** + * 页签 → 该页签可能出现的平台。 + * + * 大部分页签只有一个平台;淘系支付宝与淘系微信账户同一页签内混有 + * 天猫/淘宝/阿里巴巴的原始店铺,需要按店铺名前缀再分平台。 + */ + private const TAB_PLATFORMS = [ + 'taobao-alipay' => ['淘宝', '天猫', '阿里巴巴'], + 'taobao-wechat' => ['淘宝', '天猫'], + 'pinduoduo' => ['拼多多'], + 'jd-transactions' => ['京东'], + 'jingxi' => ['京喜'], + 'jd-wallet' => ['京东'], + 'douyin' => ['抖音'], + 'dewu' => ['得物'], + 'vipshop' => ['唯品会MP'], + 'vipshop-jx' => ['唯品会jx'], + ]; + /** 店铺名前缀可识别的平台(顺序敏感:先长后短)。 */ private const STORE_PREFIX_PLATFORMS = [ '唯品会JX' => '唯品会jx', @@ -86,6 +105,123 @@ class AccountLedgerRemarkMap return self::TAB_PLATFORM[$tab] ?? null; } + /** 页签涉及的平台(筛选下拉与账单项目翻译共用)。 */ + public function platformsForTab(string $tab): array + { + if (isset(self::TAB_PLATFORMS[$tab])) { + return self::TAB_PLATFORMS[$tab]; + } + + $default = $this->defaultPlatform($tab); + + return $default === null ? [] : [$default]; + } + + /** 平台 → 店铺名前缀(一个平台可能对应多个前缀)。 */ + private function prefixesForPlatform(string $platform): array + { + $prefixes = []; + foreach (self::STORE_PREFIX_PLATFORMS as $prefix => $candidate) { + if ($candidate === $platform) { + $prefixes[] = $prefix; + } + } + + return $prefixes; + } + + /** 页签可选的「总账备注」(该页签涉及平台的去重并集)。 */ + public function remarksForTab(string $tab): array + { + $map = $this->map(); + $remarks = []; + foreach ($this->platformsForTab($tab) as $platform) { + foreach (array_values($map[$platform] ?? []) as $remark) { + $remark = trim((string) $remark); + if ($remark !== '') { + $remarks[$remark] = true; + } + } + } + + $list = array_keys($remarks); + sort($list, SORT_NATURAL | SORT_FLAG_CASE); + + return $list; + } + + /** + * 「总账备注」→ 按平台分组的账单项目集合。 + * + * 单平台页签只有一组、且不做店铺名前缀限制,可直接 whereIn(bill_item, items); + * 多平台页签(淘系支付宝 / 淘系微信)必须连店铺名前缀一起过滤,才能与页面上 + * 按「店铺名前缀 → 平台 + 账单项目」逐行取到的总账备注完全一致。 + * + * @return list,excludePrefixes:list,items:list,isDefault:bool}> + */ + public function billItemGroupsForTab(string $tab, ?string $remark): array + { + $want = trim((string) $remark); + if ($want === '') { + return []; + } + + $map = $this->map(); + if ($map === []) { + return []; + } + + $platforms = $this->platformsForTab($tab); + $single = count($platforms) <= 1; + $default = $this->defaultPlatform($tab); + $known = array_keys(self::STORE_PREFIX_PLATFORMS); + + $groups = []; + foreach ($platforms as $platform) { + $items = []; + foreach ($map[$platform] ?? [] as $item => $value) { + if (trim((string) $value) === $want) { + $items[] = (string) $item; + } + } + if ($items === []) { + continue; + } + sort($items); + + $prefixes = $single ? [] : $this->prefixesForPlatform($platform); + $isDefault = ! $single && $platform === $default; + + $groups[] = [ + 'platform' => $platform, + // 单平台页签不做店铺名前缀限制(页签本身就是该平台口径)。 + 'prefixes' => $prefixes, + // 默认平台还要包含「没有已知平台前缀」的店铺。 + 'excludePrefixes' => $isDefault ? array_values(array_diff($known, $prefixes)) : [], + 'items' => $items, + 'isDefault' => $isDefault, + ]; + } + + return $groups; + } + + /** 「总账备注」→ 该页签全部相关账单项目(多平台页签为各平台并集)。 */ + public function billItemsForTab(string $tab, ?string $remark): array + { + $items = []; + foreach ($this->billItemGroupsForTab($tab, $remark) as $group) { + foreach ($group['items'] as $item) { + $items[$item] = true; + } + } + + $list = array_keys($items); + sort($list); + + return $list; + } + /** 解析某一行账单所属平台:优先店铺名前缀,其次页签默认。 */ public function platform(?string $storeName, ?string $tabPlatform): ?string { diff --git a/modules/EcommerceFinance/Services/LedgerRemarkFilter.php b/modules/EcommerceFinance/Services/LedgerRemarkFilter.php new file mode 100644 index 00000000..7dddbd0a --- /dev/null +++ b/modules/EcommerceFinance/Services/LedgerRemarkFilter.php @@ -0,0 +1,106 @@ + $items */ + public static function itemsWhereIn(Builder $query, array $items, string $itemColumn): void + { + $values = self::clean($items); + if ($values === []) { + return; + } + + $query->whereIn($itemColumn, $values); + } + + /** + * 按平台分组收窄查询: + * (店铺属于平台A AND 账单项目 IN A的项目) OR (店铺属于平台B AND ...) ... + * + * 店铺归属由调用方表达:普通账单表用店铺名列,共享原始账单镜像用 shop_id 子查询。 + * + * @param list,excludePrefixes?:list,items?:list}> $groups + * @param callable(Builder $sub, string $prefix): void $storeLike + * @param callable(Builder $sub, string $prefix): void $storeNotLike + */ + public static function applyGroups( + Builder $query, + array $groups, + string $itemColumn, + callable $storeLike, + callable $storeNotLike, + ): void { + $plans = []; + foreach ($groups as $group) { + $items = self::clean((array) ($group['items'] ?? [])); + if ($items === []) { + continue; + } + + $prefixes = self::clean((array) ($group['prefixes'] ?? [])); + $excludes = array_values(array_diff(self::clean((array) ($group['excludePrefixes'] ?? [])), $prefixes)); + $plans[] = ['prefixes' => $prefixes, 'excludes' => $excludes, 'items' => $items]; + } + + if ($plans === []) { + return; + } + + $query->where(function (Builder $outer) use ($plans, $itemColumn, $storeLike, $storeNotLike): void { + foreach ($plans as $plan) { + $outer->orWhere(function (Builder $sub) use ($plan, $itemColumn, $storeLike, $storeNotLike): void { + if ($plan['prefixes'] !== [] || $plan['excludes'] !== []) { + $sub->where(function (Builder $scope) use ($plan, $storeLike, $storeNotLike): void { + if ($plan['prefixes'] !== []) { + // 本平台前缀的店铺 + $scope->orWhere(function (Builder $own) use ($plan, $storeLike): void { + foreach ($plan['prefixes'] as $prefix) { + $storeLike($own, $prefix); + } + }); + } + if ($plan['excludes'] !== []) { + // 没有其它平台前缀的店铺归默认平台 + $scope->orWhere(function (Builder $other) use ($plan, $storeNotLike): void { + foreach ($plan['excludes'] as $prefix) { + $storeNotLike($other, $prefix); + } + }); + } + }); + } + + $sub->whereIn($itemColumn, $plan['items']); + }); + } + }); + } + + /** + * @param array $values + * @return list + */ + private static function clean(array $values): array + { + $clean = []; + foreach ($values as $value) { + $text = trim((string) $value); + if ($text !== '') { + $clean[$text] = true; + } + } + + return array_keys($clean); + } +} diff --git a/resources/assets/js/components/business/AccountBills.vue b/resources/assets/js/components/business/AccountBills.vue index 2b42fad0..d874909a 100644 --- a/resources/assets/js/components/business/AccountBills.vue +++ b/resources/assets/js/components/business/AccountBills.vue @@ -10,6 +10,12 @@ + +