店铺收款账户账单:所有页签新增「总账备注」筛选(页面与导出同口径)

- 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 断言
This commit is contained in:
qiuxiang 2026-10-06 18:46:09 +08:00
parent 24ded6fe7f
commit 7175d3f73e
25 changed files with 624 additions and 52 deletions

View File

@ -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<string,mixed> $filters
* @return array<string,mixed>
*/
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 行数组)补总账备注。
*

View File

@ -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<string,string> */
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

View File

@ -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;

View File

@ -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'],

View File

@ -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'],

View File

@ -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'],

View File

@ -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 [

View File

@ -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'] ?? '') !== '') {

View File

@ -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'] ?? '') !== '') {

View File

@ -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 [

View File

@ -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<string,scalar|list<scalar>> $fixedFilters */
private function channelCoversDataset(string|array|null $channel, array $fixedFilters): bool
{

View File

@ -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'] ?? '') !== '') {

View File

@ -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);

View File

@ -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 [

View File

@ -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<array{platform:string,prefixes:list<string>,excludePrefixes:list<string>,items:list<string>,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
{

View File

@ -0,0 +1,106 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Database\Query\Builder;
/**
* 「总账备注」筛选的 SQL 条件翻译(页面与导出共用)。
*
* 单平台页签:账单项目 IN (该平台下映射到所选总账备注的账单项目)。
* 多平台页签(淘系支付宝 / 淘系微信):同一页签混有天猫、淘宝、阿里巴巴店铺,
* 必须按店铺名前缀分平台,否则同名账单项目在别的平台可能对应另一个总账备注。
*/
final class LedgerRemarkFilter
{
/** @param list<string> $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<array{prefixes?:list<string>,excludePrefixes?:list<string>,items?:list<string>}> $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<int,mixed> $values
* @return list<string>
*/
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);
}
}

View File

@ -10,6 +10,12 @@
</el-select>
<input v-else v-model.trim="filters.store_name" placeholder="店铺名称" />
</label>
<label>总账备注
<el-select v-if="usesMirrorOptions && !filterOptionsError" v-model="filters.ledger_remark" filterable clearable :loading="filterOptionsLoading" placeholder="全部总账备注" aria-label="总账备注筛选" popper-class="account-bill-filter-options" no-match-text="没有匹配的总账备注" no-data-text="暂无总账备注" @change="applyFilters">
<el-option v-for="remark in filterOptions.ledger_remarks" :key="remark" :label="remark" :value="remark" />
</el-select>
<input v-else v-model.trim="filters.ledger_remark" placeholder="总账备注" />
</label>
<label>账单项目
<el-select v-if="usesMirrorOptions && !filterOptionsError" v-model="filters.bill_item" filterable clearable :loading="filterOptionsLoading" placeholder="全部账单项目" aria-label="账单项目筛选" popper-class="account-bill-filter-options" no-match-text="没有匹配的账单项目" no-data-text="暂无账单项目" @change="applyFilters">
<el-option v-for="item in filterOptions.bill_items" :key="item" :label="item" :value="item" />
@ -79,10 +85,10 @@ const JD_BILL_TABS = ['jd-transactions','jingxi'];
// 时间筛选标签与该页签的时间列名保持一致(其余页签沿用「发生时间」)。
const DATE_FILTER_LABELS = {'jd-transactions':'费用结算时间','jingxi':'费用结算时间','jd-wallet':'费用结算时间','douyin':'结算时间','vipshop':'结算时间','vipshop-jx':'结算时间'};
const dateConfig = {dateFormat:'Y-m-d',locale:Mandarin,disableMobile:true,allowInput:false,onReady:(_,value,picker)=>picker.calendarContainer.classList.add('account-bill-calendar')};
const emptyFilters = billType => ({store_name:'',bill_item:'',start:'',end:'',keyword:'',keyword_match:billType==='douyin'?'exact-record':'contains'});
const emptyFilters = billType => ({store_name:'',ledger_remark:'',bill_item:'',start:'',end:'',keyword:'',keyword_match:billType==='douyin'?'exact-record':'contains'});
export default {
name:'AccountBills', components:{ TableColumnSettings, flatPicker, [Select.name]:Select, [Option.name]:Option }, props:{ apiBase:{type:String,required:true}, billType:{type:String,required:true}, userKey:{type:[String,Number],required:true} },
data(){return{loading:false,loadError:false,sync:null,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:emptyFilters(this.billType),queryFilters:emptyFilters(this.billType),resultFilters:null,filterOptions:{shops:[],bill_items:[]},filterOptionsLoading:false,filterOptionsError:false,requestSerial:0,sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible'};},
data(){return{loading:false,loadError:false,sync:null,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:emptyFilters(this.billType),queryFilters:emptyFilters(this.billType),resultFilters:null,filterOptions:{shops:[],bill_items:[],ledger_remarks:[]},filterOptionsLoading:false,filterOptionsError:false,requestSerial:0,sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible'};},
computed:{
isDouyin(){return this.billType==='douyin';},
// 费用结算时间标签、来源说明与收支净额统计口径按京东系列页签(京东账单/京喜账单)统一。
@ -104,7 +110,7 @@ export default {
},
mounted(){if(this.usesMirrorOptions)this.fetchFilterOptions();this.fetch();},
methods:{
async fetchFilterOptions(){this.filterOptionsLoading=true;this.filterOptionsError=false;try{const response=await window.axios.get(`${this.apiBase}/filter-options`);const body=response.data||{};this.filterOptions={shops:Array.isArray(body.shops)?body.shops:[],bill_items:Array.isArray(body.bill_items)?body.bill_items:[]};}catch(error){this.filterOptionsError=true;}finally{this.filterOptionsLoading=false;}},
async fetchFilterOptions(){this.filterOptionsLoading=true;this.filterOptionsError=false;try{const response=await window.axios.get(`${this.apiBase}/filter-options`);const body=response.data||{};this.filterOptions={shops:Array.isArray(body.shops)?body.shops:[],bill_items:Array.isArray(body.bill_items)?body.bill_items:[],ledger_remarks:Array.isArray(body.ledger_remarks)?body.ledger_remarks:[]};}catch(error){this.filterOptionsError=true;}finally{this.filterOptionsLoading=false;}},
async fetch(){const requestId=++this.requestSerial;const requestedFilters={...this.queryFilters};this.loading=true;this.loadError=false;try{const response=await window.axios.get(this.apiBase,{params:{...requestedFilters,sort:this.sort.key,dir:this.sort.dir,limit:this.perPage,page:this.currentPage}});if(requestId!==this.requestSerial)return;const body=response.data||{};this.resultFilters=requestedFilters;this.sync=body.sync||null;this.rows=body.data||[];this.total=body.total||0;this.currentPage=body.current_page||1;this.lastPage=body.last_page||1;this.perPage=body.per_page||this.perPage;this.summary=body.summary||{income:0,expense:0,balance:0,count:0};if(body.columns){this.columnsTitle=body.columns.title||'';this.sourceGroups=body.columns.groups||[];this.sourceColumns=this.attachGroups(body.columns.columns||body.columns||[],this.sourceGroups);}}catch(error){if(requestId!==this.requestSerial)return;this.rows=[];this.total=0;this.sync=null;this.summary={income:0,expense:0,balance:0,count:0};this.loadError=true;}finally{if(requestId===this.requestSerial)this.loading=false;}},
attachGroups(columns,groups){let index=0;return columns.map((column,columnIndex)=>{while(groups[index]&&columnIndex>=groups.slice(0,index+1).reduce((sum,group)=>sum+Number(group.span||0),0))index+=1;return{...column,key:column.field||`column-${columnIndex}`,group:groups[index]?groups[index].label:''};});},
applyFilters(){if(this.invalidDateRange)return;this.queryFilters={...this.filters};this.currentPage=1;return this.fetch();},resetFilters(){this.filters=emptyFilters(this.billType);return this.applyFilters();},changePage(page){this.currentPage=page;this.fetch();this.$nextTick(()=>{if(this.$refs.scroll)this.$refs.scroll.scrollTop=0;});},

View File

@ -15,6 +15,12 @@
</el-select>
<input v-else v-model.trim="filters.store_name" placeholder="店铺名称" />
</label>
<label>总账备注
<el-select v-if="!filterOptionsError" v-model="filters.ledger_remark" filterable clearable :loading="filterOptionsLoading" placeholder="全部总账备注" aria-label="总账备注筛选" popper-class="alipay-bill-filter-options" no-match-text="没有匹配的总账备注" no-data-text="暂无总账备注" @change="applyFilters">
<el-option v-for="remark in filterOptions.ledger_remarks" :key="remark" :label="remark" :value="remark" />
</el-select>
<input v-else v-model.trim="filters.ledger_remark" placeholder="总账备注" />
</label>
<label>数据源渠道<input v-model.trim="filters.source_channel" placeholder="如 alipayCSV" /></label>
<label>账单项目
<el-select v-if="!filterOptionsError" v-model="filters.bill_item" filterable clearable :loading="filterOptionsLoading" placeholder="全部账单项目" aria-label="账单项目筛选" popper-class="alipay-bill-filter-options" no-match-text="没有匹配的账单项目" no-data-text="暂无账单项目" @change="applyFilters">
@ -114,7 +120,7 @@ const SORTABLE = ['occurred_at', 'income', 'expense', 'balance', 'record_code'];
export default {
name: 'TaobaoAlipayBills', components: { TableColumnSettings, flatPicker, [Select.name]: Select, [Option.name]: Option },
props: { apiBase: { type: String, required: true }, exportBase: { type: String, required: true }, userKey: { type: [String, Number], required: true } },
data() { return { loading: false, error: '', rows: [], configuredColumns: [], summary: { income: 0, expense: 0, balance: 0, count: 0 }, total: 0, currentPage: 1, lastPage: 1, perPage: 20, filters: { store_name: '', source_channel: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }, filterOptions: { shops: [], bill_items: [] }, filterOptionsLoading: false, filterOptionsError: false, sort: { key: 'occurred_at', dir: 'desc' }, exportMode: 'filtered-visible' }; },
data() { return { loading: false, error: '', rows: [], configuredColumns: [], summary: { income: 0, expense: 0, balance: 0, count: 0 }, total: 0, currentPage: 1, lastPage: 1, perPage: 20, filters: { store_name: '', ledger_remark: '', source_channel: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }, filterOptions: { shops: [], bill_items: [], ledger_remarks: [] }, filterOptionsLoading: false, filterOptionsError: false, sort: { key: 'occurred_at', dir: 'desc' }, exportMode: 'filtered-visible' }; },
computed: {
defaultColumns() { return COLUMNS; }, visibleColumns() { return this.configuredColumns.filter((column) => column.visible); },
visibleGroups() { return this.visibleColumns.reduce((groups, column) => { const last = groups[groups.length - 1]; if (last && last.label === column.group) last.span += 1; else groups.push({ label: column.group, span: 1 }); return groups; }, []); },
@ -126,9 +132,9 @@ export default {
},
mounted() { this.fetchFilterOptions(); this.fetch(); },
methods: {
async fetchFilterOptions() { this.filterOptionsLoading = true; this.filterOptionsError = false; try { const response = await window.axios.get(`${this.apiBase}/filter-options`); const body = response.data || {}; this.filterOptions = { shops: Array.isArray(body.shops) ? body.shops : [], bill_items: Array.isArray(body.bill_items) ? body.bill_items : [] }; } catch (error) { this.filterOptionsError = true; } finally { this.filterOptionsLoading = false; } },
async fetchFilterOptions() { this.filterOptionsLoading = true; this.filterOptionsError = false; try { const response = await window.axios.get(`${this.apiBase}/filter-options`); const body = response.data || {}; this.filterOptions = { shops: Array.isArray(body.shops) ? body.shops : [], bill_items: Array.isArray(body.bill_items) ? body.bill_items : [], ledger_remarks: Array.isArray(body.ledger_remarks) ? body.ledger_remarks : [] }; } catch (error) { this.filterOptionsError = true; } finally { this.filterOptionsLoading = false; } },
async fetch() { this.loading = true; this.error = ''; try { const response = await window.axios.get(this.apiBase, { params: { ...this.filters, sort: this.sort.key, dir: this.sort.dir, limit: this.perPage, page: this.currentPage } }); const body = response.data || {}; this.rows = body.data || []; this.total = body.total || 0; this.currentPage = body.current_page || 1; this.lastPage = body.last_page || 1; this.perPage = body.per_page || this.perPage; this.summary = body.summary || { income: 0, expense: 0, balance: 0, count: 0 }; } catch (error) { this.rows = []; this.total = 0; this.summary = { income: 0, expense: 0, balance: 0, count: 0 }; this.error = (error.response && error.response.data && error.response.data.message) || '淘系支付宝账单暂时无法读取,请稍后重试。'; } finally { this.loading = false; } },
applyFilters() { if (this.invalidDateRange) return; this.currentPage = 1; this.fetch(); }, resetFilters() { this.filters = { store_name: '', source_channel: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }; this.applyFilters(); },
applyFilters() { if (this.invalidDateRange) return; this.currentPage = 1; this.fetch(); }, resetFilters() { this.filters = { store_name: '', ledger_remark: '', source_channel: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }; this.applyFilters(); },
openDatePicker(refName) { const picker = this.$refs[refName]; if (picker && picker.fp) picker.fp.open(); },
changeDate(field, value) { const date = value || ''; if (this.filters[field] === date) return; this.filters[field] = date; this.applyFilters(); },
changePage(page) { this.currentPage = page; this.fetch(); this.$nextTick(() => { if (this.$refs.scroll) this.$refs.scroll.scrollTop = 0; }); },

View File

@ -91,6 +91,7 @@ class DewuReceivingBillsTest extends FeatureTestCase
->assertExactJson([
'shops' => ['得物-A店', '得物-B店'],
'bill_items' => ['交易收入', '平台服务费'],
'ledger_remarks' => [],
]);
$response = $this->loginAs()->get(route('ecommerce-finance.account-bills.export', [

View File

@ -496,7 +496,7 @@ class PagesTest extends FeatureTestCase
$this->loginAs()
->getJson(route('ecommerce-finance.alipay-bills.filter-options'))
->assertOk()
->assertExactJson(['shops' => ['天猫A店'], 'bill_items' => ['交易收款']]);
->assertExactJson(['shops' => ['天猫A店'], 'bill_items' => ['交易收款'], 'ledger_remarks' => []]);
}
public function testTaobaoAlipayFilterOptionsReportAnUnreadyMirror(): void
@ -512,7 +512,8 @@ class PagesTest extends FeatureTestCase
->getJson(route('ecommerce-finance.alipay-bills.filter-options'))
->assertStatus(503)
->assertJsonPath('shops', [])
->assertJsonPath('bill_items', []);
->assertJsonPath('bill_items', [])
->assertJsonPath('ledger_remarks', []);
}
public function testTaobaoAlipayBillsPageProvidesTheVueMountPoint()
@ -657,6 +658,7 @@ class PagesTest extends FeatureTestCase
->assertExactJson([
'shops' => ['放心购旗舰店', '放心购专营店'],
'bill_items' => ['订单收入', '交易佣金'],
'ledger_remarks' => [],
]);
}

View File

@ -82,6 +82,7 @@ class PinduoduoReceivingBillsTest extends FeatureTestCase
->assertExactJson([
'shops' => ['拼多多-A店', '拼多多-B店'],
'bill_items' => ['交易收入', '技术服务费'],
'ledger_remarks' => [],
]);
$response = $this->loginAs()->get(route('ecommerce-finance.account-bills.export', [

View File

@ -81,6 +81,7 @@ class TaobaoWechatReceivingBillsTest extends FeatureTestCase
->assertExactJson([
'shops' => ['淘系微信-A店', '淘系微信-B店'],
'bill_items' => ['交易收入', '技术服务费'],
'ledger_remarks' => [],
]);
$response = $this->loginAs()->get(route('ecommerce-finance.account-bills.export', [

View File

@ -3,6 +3,8 @@
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
use Modules\EcommerceFinance\Models\AccountBill;
use Tests\Feature\FeatureTestCase;
@ -57,6 +59,7 @@ class VipshopJxReceivingBillsTest extends FeatureTestCase
->assertExactJson([
'shops' => ['唯品会JITX_测试店'],
'bill_items' => ['客退', '销售'],
'ledger_remarks' => [],
]);
$export = $this->loginAs()->get(route('ecommerce-finance.account-bills.export', [
@ -73,6 +76,48 @@ class VipshopJxReceivingBillsTest extends FeatureTestCase
$this->assertStringNotContainsString('999.00', $csv);
}
public function test_ledger_remark_filter_uses_mapped_bill_items_on_page_and_export(): void
{
$this->allowPage();
$this->bill('vipshop-jx', '26060921475309', '销售', '2026-06-09 00:00:00', 82.48, 0);
$this->bill('vipshop-jx', '26070393670736', '客退', '2026-07-03 00:00:00', 0, 68.60);
$this->bill('vipshop', '26060100000001', '订单退款', '2026-06-01 00:00:00', 0, 999.00);
$now = now();
DB::table('ec_account_ledger_remarks')->insert([
['platform' => '唯品会jx', 'bill_item' => '销售', 'ledger_remark' => '订单', 'created_at' => $now, 'updated_at' => $now],
['platform' => '唯品会jx', 'bill_item' => '客退', 'ledger_remark' => '订单退款', 'created_at' => $now, 'updated_at' => $now],
]);
app(AccountLedgerRemarkMap::class)->forget();
$this->loginAs()
->getJson(route('ecommerce-finance.account-bills.filter-options', ['bill_type' => 'vipshop-jx']))
->assertOk()
->assertExactJson([
'shops' => ['唯品会JITX_测试店'],
'bill_items' => ['客退', '销售'],
'ledger_remarks' => ['订单', '订单退款'],
]);
$page = $this->loginAs()->getJson(route('ecommerce-finance.account-bills.index', [
'bill_type' => 'vipshop-jx',
'ledger_remark' => '订单',
'limit' => 20,
]))->assertOk();
$this->assertSame(1, $page->json('total'));
$this->assertSame('销售', $page->json('data.0.bill_item'));
$this->assertSame(82.48, (float) $page->json('data.0.income'));
$export = $this->loginAs()->get(route('ecommerce-finance.account-bills.export', [
'bill_type' => 'vipshop-jx',
'ledger_remark' => '订单',
]));
$export->assertOk()->assertDownload('vipshop-jx_account_bills.csv');
$csv = $export->streamedContent();
$this->assertStringContainsString('26060921475309', $csv);
$this->assertStringNotContainsString('26070393670736', $csv);
}
private function bill(string $billType, string $orderNo, string $item, string $occurredAt, float $income, float $expense): void
{
AccountBill::create([

View File

@ -55,6 +55,50 @@ class AccountLedgerRemarkMapTest extends TestCase
}
}
public function test_ledger_remark_filter_options_and_bill_items_are_scoped_per_tab(): void
{
$now = now();
DB::table('ec_account_ledger_remarks')->insert([
['platform' => '京东', 'bill_item' => '货款(收)', 'ledger_remark' => '订单收入', 'created_at' => $now, 'updated_at' => $now],
['platform' => '京东', 'bill_item' => '佣金', 'ledger_remark' => '佣金', 'created_at' => $now, 'updated_at' => $now],
['platform' => '京喜', 'bill_item' => '货款(收)', 'ledger_remark' => '订单收入', 'created_at' => $now, 'updated_at' => $now],
['platform' => '淘宝', 'bill_item' => '服务费', 'ledger_remark' => '违约赔付', 'created_at' => $now, 'updated_at' => $now],
['platform' => '天猫', 'bill_item' => '服务费', 'ledger_remark' => '服务费', 'created_at' => $now, 'updated_at' => $now],
]);
$map = new AccountLedgerRemarkMap();
$this->assertSame(['佣金', '订单收入'], $map->remarksForTab('jd-transactions'));
$this->assertSame(['订单收入'], $map->remarksForTab('jingxi'));
$this->assertSame(['货款(收)'], $map->billItemsForTab('jd-transactions', '订单收入'));
$this->assertSame([], $map->billItemsForTab('jd-transactions', '不存在的备注'));
// 单平台页签不做店铺名前缀限制,直接按账单项目集合过滤。
$single = $map->billItemGroupsForTab('jd-transactions', '订单收入');
$this->assertCount(1, $single);
$this->assertFalse($single[0]['isDefault']);
$this->assertSame([], $single[0]['prefixes']);
$this->assertSame([], $single[0]['excludePrefixes']);
$this->assertSame(['货款(收)'], $single[0]['items']);
// 淘系支付宝同一页签混有淘宝/天猫店铺:同名账单项目在两边对应不同总账备注。
$taobao = $map->billItemGroupsForTab('taobao-alipay', '违约赔付');
$this->assertCount(1, $taobao);
$this->assertSame('淘宝', $taobao[0]['platform']);
$this->assertTrue($taobao[0]['isDefault']);
$this->assertSame(['淘宝'], $taobao[0]['prefixes']);
$this->assertContains('天猫', $taobao[0]['excludePrefixes']);
$this->assertSame(['服务费'], $taobao[0]['items']);
$tmall = $map->billItemGroupsForTab('taobao-alipay', '服务费');
$this->assertCount(1, $tmall);
$this->assertSame('天猫', $tmall[0]['platform']);
$this->assertFalse($tmall[0]['isDefault']);
$this->assertSame(['天猫'], $tmall[0]['prefixes']);
$this->assertSame([], $tmall[0]['excludePrefixes']);
$this->assertSame(['服务费'], $tmall[0]['items']);
}
public function test_it_matches_platform_and_bill_item_then_decorates_rows(): void
{
$now = now();

View File

@ -0,0 +1,62 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\LedgerRemarkFilter;
use Tests\TestCase;
class LedgerRemarkFilterTest extends TestCase
{
public function test_single_platform_filter_uses_bill_item_list(): void
{
$query = DB::connection()->table('ec_account_bills');
LedgerRemarkFilter::itemsWhereIn($query, ['销售', '客退'], 'bill_item');
LedgerRemarkFilter::itemsWhereIn($query, ['', ' '], 'bill_item');
$this->assertStringContainsString('in (?, ?)', $query->toSql());
$this->assertSame(['销售', '客退'], $query->getBindings());
}
public function test_multi_platform_filter_scopes_store_prefixes_per_platform(): void
{
$query = DB::connection()->table('taobao_wechat_bill_rows');
LedgerRemarkFilter::applyGroups(
$query,
[
['platform' => '淘宝', 'prefixes' => ['淘宝'], 'excludePrefixes' => ['天猫'], 'items' => ['服务费'], 'isDefault' => true],
['platform' => '天猫', 'prefixes' => ['天猫'], 'excludePrefixes' => [], 'items' => ['服务费'], 'isDefault' => false],
],
'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 . '%'),
);
$sql = $query->toSql();
$this->assertStringContainsString('"store_name" like ?', $sql);
$this->assertStringContainsString('"store_name" not like ?', $sql);
$this->assertStringContainsString('"bill_item" in (?)', $sql);
// 淘宝组(本平台前缀 或 不含其它已知平台前缀)+ 天猫组
$this->assertSame(['淘宝%', '天猫%', '服务费', '天猫%', '服务费'], $query->getBindings());
}
public function test_empty_group_list_does_not_touch_the_query(): void
{
$query = DB::connection()->table('ec_account_bills');
$before = $query->toSql();
LedgerRemarkFilter::applyGroups(
$query,
[],
'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 . '%'),
);
$this->assertSame($before, $query->toSql());
$this->assertSame([], $query->getBindings());
}
}