接入头条放心购抖音账户账单
This commit is contained in:
parent
b66f223ebb
commit
79f408156d
@ -7,6 +7,8 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\AccountBillColumns;
|
||||
use Modules\EcommerceFinance\Models\AccountBill;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
|
||||
use RuntimeException;
|
||||
|
||||
class AccountBills extends Controller
|
||||
{
|
||||
@ -25,6 +27,12 @@ class AccountBills extends Controller
|
||||
abort(404, '未知的账户类型。');
|
||||
}
|
||||
|
||||
if ($billType === 'douyin') {
|
||||
return $request->boolean('export')
|
||||
? $this->export($request, $billType)
|
||||
: $this->douyinIndex($request);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'store_name' => ['nullable', 'string', 'max:255'],
|
||||
'bill_item' => ['nullable', 'string', 'max:255'],
|
||||
@ -72,7 +80,7 @@ class AccountBills extends Controller
|
||||
$query->orderBy($sort, $dir);
|
||||
|
||||
if ($request->boolean('export')) {
|
||||
return $this->export($request, $query, $billType);
|
||||
return $this->exportQuery($request, $query, $billType);
|
||||
}
|
||||
|
||||
$perPage = min(max((int) $request->get('limit', 10), 1), 100);
|
||||
@ -102,11 +110,43 @@ class AccountBills extends Controller
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
private function export(Request $request, $query, string $billType)
|
||||
public function export(Request $request, string $bill_type)
|
||||
{
|
||||
$this->authorizeStoreReceivingBills();
|
||||
|
||||
abort_unless(AccountBillColumns::has($bill_type), 404, '未知的账户类型。');
|
||||
|
||||
if ($bill_type === 'douyin') {
|
||||
return $this->exportDouyin($request);
|
||||
}
|
||||
|
||||
$filters = $request->validate([
|
||||
'store_name' => ['nullable', 'string', 'max:255'],
|
||||
'bill_item' => ['nullable', 'string', 'max:255'],
|
||||
'start' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'],
|
||||
'keyword' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
$query = $this->filteredQuery($request, $bill_type);
|
||||
$sortable = ['occurred_at', 'income', 'expense', 'balance', 'record_code'];
|
||||
$sort = in_array($request->get('sort'), $sortable, true) ? $request->get('sort') : 'occurred_at';
|
||||
$dir = $request->get('dir') === 'asc' ? 'asc' : 'desc';
|
||||
$query->orderBy($sort, $dir);
|
||||
|
||||
return $this->exportQuery($request, $query, $bill_type);
|
||||
}
|
||||
|
||||
private function exportQuery(Request $request, $query, string $billType)
|
||||
{
|
||||
$definition = AccountBillColumns::for($billType);
|
||||
$available = collect($definition['columns'] ?? [])->keyBy('field');
|
||||
$requested = $request->input('columns', []);
|
||||
if (is_string($requested)) {
|
||||
$requested = array_filter(array_map('trim', explode(',', $requested)));
|
||||
}
|
||||
if (! is_array($requested)) {
|
||||
$requested = [];
|
||||
}
|
||||
$columns = $requested === []
|
||||
? $available->values()
|
||||
: collect($requested)->unique()->map(fn (string $field) => $available->get($field))->filter()->values();
|
||||
@ -126,7 +166,10 @@ class AccountBills extends Controller
|
||||
})->all());
|
||||
}
|
||||
fclose($csv);
|
||||
}, $billType . '_account_bills.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}, $billType . '_account_bills.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
private function columnValue(AccountBill $row, string $field): mixed
|
||||
@ -148,6 +191,175 @@ class AccountBills extends Controller
|
||||
return preg_match('/^[=+\-@]/', $text) ? "'" . $text : $text;
|
||||
}
|
||||
|
||||
private function filteredQuery(Request $request, string $billType)
|
||||
{
|
||||
$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): void {
|
||||
$like = '%' . $keyword . '%';
|
||||
$q->where('record_code', 'like', $like)
|
||||
->orWhere('order_no', 'like', $like)
|
||||
->orWhere('remark', 'like', $like)
|
||||
->orWhere('bill_item', 'like', $like);
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function douyinIndex(Request $request)
|
||||
{
|
||||
$filters = $this->douyinFilters($request);
|
||||
$sort = $this->sort($request);
|
||||
$direction = $this->direction($request);
|
||||
$page = max((int) $request->get('page', 1), 1);
|
||||
$perPage = min(max((int) $request->get('limit', 10), 1), 100);
|
||||
|
||||
try {
|
||||
$data = app(StoreReceivingDouyinBillMirrorRepository::class)->paginate(
|
||||
$filters,
|
||||
$sort,
|
||||
$direction,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => '抖音账户账单共享业务镜像正在首次同步,完成后将自动显示。',
|
||||
'data' => [],
|
||||
'total' => 0,
|
||||
'current_page' => 1,
|
||||
'last_page' => 1,
|
||||
'per_page' => $perPage,
|
||||
'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0],
|
||||
'columns' => AccountBillColumns::for('douyin'),
|
||||
], 503);
|
||||
}
|
||||
|
||||
$data['columns'] = AccountBillColumns::for('douyin');
|
||||
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
private function exportDouyin(Request $request)
|
||||
{
|
||||
$filters = $this->douyinFilters($request);
|
||||
$definition = AccountBillColumns::for('douyin');
|
||||
$columns = collect($definition['columns'] ?? []);
|
||||
$requested = $request->query('columns', []);
|
||||
if (is_string($requested)) {
|
||||
$requested = array_filter(array_map('trim', explode(',', $requested)));
|
||||
}
|
||||
if (! is_array($requested)) {
|
||||
$requested = [];
|
||||
}
|
||||
$requested = collect($requested)->map(static fn (string $field): string => trim($field))->filter()->unique()->values();
|
||||
if ($requested->isEmpty()) {
|
||||
$selected = $columns;
|
||||
} else {
|
||||
$columnsByField = $columns->keyBy('field');
|
||||
$selected = $requested
|
||||
->map(static fn (string $field) => $columnsByField->get($field))
|
||||
->filter(static fn ($column): bool => is_array($column))
|
||||
->values();
|
||||
abort_if($selected->isEmpty(), 422, '未选择有效的导出字段。');
|
||||
}
|
||||
|
||||
$sort = $this->sort($request);
|
||||
$direction = $this->direction($request);
|
||||
try {
|
||||
$rows = app(StoreReceivingDouyinBillMirrorRepository::class)
|
||||
->exportRows($filters, $sort, $direction);
|
||||
} catch (RuntimeException $exception) {
|
||||
if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return response()->json(['message' => '抖音账户账单共享业务镜像正在首次同步,完成后将自动导出。'], 503);
|
||||
}
|
||||
|
||||
$headers = $selected->pluck('label')->all();
|
||||
|
||||
return response()->streamDownload(function () use ($rows, $selected, $headers): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
fputs($csv, "\xEF\xBB\xBF");
|
||||
fputcsv($csv, $headers);
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($csv, $selected->map(
|
||||
fn (array $column): string => $this->exportArrayValue($row, $column)
|
||||
)->all());
|
||||
}
|
||||
fclose($csv);
|
||||
}, 'douyin_account_bills.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private function douyinFilters(Request $request): array
|
||||
{
|
||||
$filters = $request->validate([
|
||||
'store_name' => ['nullable', 'string', 'max:255'],
|
||||
'bill_item' => ['nullable', 'string', 'max:255'],
|
||||
'start' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'],
|
||||
'keyword' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
return array_map(static fn (mixed $value): string => trim((string) $value), $filters);
|
||||
}
|
||||
|
||||
private function sort(Request $request): string
|
||||
{
|
||||
return in_array($request->get('sort'), ['occurred_at', 'income', 'expense', 'balance', 'record_code'], true)
|
||||
? (string) $request->get('sort')
|
||||
: 'occurred_at';
|
||||
}
|
||||
|
||||
private function direction(Request $request): string
|
||||
{
|
||||
return $request->get('dir') === 'asc' ? 'asc' : 'desc';
|
||||
}
|
||||
|
||||
private function exportArrayValue(array $row, array $column): string
|
||||
{
|
||||
$field = (string) ($column['field'] ?? '');
|
||||
$value = str_starts_with($field, 'payload.')
|
||||
? data_get($row['payload'] ?? [], substr($field, 8))
|
||||
: data_get($row, $field);
|
||||
|
||||
$text = trim((string) ($value ?? ''));
|
||||
if (preg_match('/^\d{12,}$/', $text) === 1) {
|
||||
return '="' . $text . '"';
|
||||
}
|
||||
|
||||
return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text;
|
||||
}
|
||||
|
||||
public function show(AccountBill $bill)
|
||||
{
|
||||
$this->authorizeStoreReceivingBills();
|
||||
|
||||
@ -104,7 +104,14 @@ class AlipayBills extends Controller
|
||||
unset($filters['columns']);
|
||||
|
||||
try {
|
||||
$rows = $repository->exportRows(array_map(static fn (mixed $value): string => trim((string) $value), $filters));
|
||||
$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,
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') {
|
||||
throw $exception;
|
||||
|
||||
@ -20,6 +20,26 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
|
||||
/** @return array{data:array<int,array<string,mixed>>,total:int,current_page:int,last_page:int,per_page:int,summary:array{income:float,expense:float,balance:float,count:int},sync:array<string,mixed>} */
|
||||
public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array
|
||||
{
|
||||
return $this->paginateChannel($filters, $sort, $direction, $page, $perPage, null, 'taobao');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one upstream platform channel from the shared original-bill mirror.
|
||||
* The mirror is shared by several account pages; callers must provide a
|
||||
* fixed source channel rather than accepting arbitrary SQL/filter input.
|
||||
*
|
||||
* @return array{data:array<int,array<string,mixed>>,total:int,current_page:int,last_page:int,per_page:int,summary:array{income:float,expense:float,balance:float,count:int},sync:array<string,mixed>}
|
||||
*/
|
||||
public function paginateChannel(
|
||||
array $filters,
|
||||
string $sort,
|
||||
string $direction,
|
||||
int $page,
|
||||
int $perPage,
|
||||
?string $channel,
|
||||
string $mapping = 'taobao',
|
||||
): array
|
||||
{
|
||||
$dataset = $this->datasets->get(self::DATASET);
|
||||
if (! $this->mirror->ready($dataset)
|
||||
@ -39,16 +59,17 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
][$sort] ?? 'b.fee_time';
|
||||
|
||||
$query = $this->billQuery();
|
||||
$this->applyChannel($query, $channel);
|
||||
$this->applyFilters($query, $filters);
|
||||
$state = $this->mirror->state($dataset) ?? [];
|
||||
// The synchronizer persists an exact COUNT in the same transaction that
|
||||
// marks a run successful. Never substitute a database row estimate.
|
||||
$hasFilters = collect($filters)->contains(static fn (mixed $value): bool => $value !== null && trim((string) $value) !== '');
|
||||
$recordedCount = $state['mirror_row_count'] ?? null;
|
||||
$useRecordedCount = ! $hasFilters && ($state['status'] ?? null) === 'succeeded'
|
||||
$useRecordedCount = $channel === null && ! $hasFilters && ($state['status'] ?? null) === 'succeeded'
|
||||
&& $recordedCount !== null && ctype_digit((string) $recordedCount);
|
||||
$total = $useRecordedCount ? (int) $recordedCount : (clone $query)->count();
|
||||
$summary = $this->summary($query, $filters);
|
||||
$summary = $this->summary($query, $filters, $channel);
|
||||
|
||||
$billRows = $query
|
||||
->select('b.*')
|
||||
@ -59,7 +80,7 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
->all();
|
||||
$shopNames = $this->shopNames($billRows);
|
||||
$rows = collect($billRows)
|
||||
->map(fn (object $row): array => $this->mapRow($row, $shopNames[(string) $row->shop_id] ?? null))
|
||||
->map(fn (object $row): array => $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping))
|
||||
->all();
|
||||
$income = $summary['income'];
|
||||
$expense = $summary['expense'];
|
||||
@ -84,7 +105,19 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
}
|
||||
|
||||
/** @return iterable<int,array<string,mixed>> */
|
||||
public function exportRows(array $filters): iterable
|
||||
public function exportRows(array $filters, string $sort = 'occurred_at', string $direction = 'asc'): iterable
|
||||
{
|
||||
return $this->exportChannelRows($filters, $sort, $direction, null, 'taobao');
|
||||
}
|
||||
|
||||
/** @return iterable<int,array<string,mixed>> */
|
||||
public function exportChannelRows(
|
||||
array $filters,
|
||||
string $sort = 'occurred_at',
|
||||
string $direction = 'asc',
|
||||
?string $channel = null,
|
||||
string $mapping = 'taobao',
|
||||
): iterable
|
||||
{
|
||||
$dataset = $this->datasets->get(self::DATASET);
|
||||
if (! $this->mirror->ready($dataset)
|
||||
@ -93,12 +126,21 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
}
|
||||
|
||||
$query = $this->billQuery();
|
||||
$this->applyChannel($query, $channel);
|
||||
$this->applyFilters($query, $filters);
|
||||
$shopNames = $this->allShopNames();
|
||||
$direction = $direction === 'desc' ? 'desc' : 'asc';
|
||||
$sortColumn = [
|
||||
'record_code' => 'b.Id',
|
||||
'occurred_at' => 'b.fee_time',
|
||||
'income' => 'b.amount',
|
||||
'expense' => 'b.amount',
|
||||
'balance' => 'b.amount',
|
||||
][$sort] ?? 'b.fee_time';
|
||||
|
||||
return $query->select('b.*')
|
||||
->orderBy('b.fee_time')->orderBy('b.Id')->cursor()
|
||||
->map(fn (object $row): array => $this->mapRow($row, $shopNames[(string) $row->shop_id] ?? null));
|
||||
->orderBy($sortColumn, $direction)->orderBy('b.Id', $direction)->cursor()
|
||||
->map(fn (object $row): array => $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping));
|
||||
}
|
||||
|
||||
private function billQuery(): Builder
|
||||
@ -107,9 +149,9 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
}
|
||||
|
||||
/** @return array{income:float,expense:float} */
|
||||
private function summary(Builder $billQuery, array $filters): array
|
||||
private function summary(Builder $billQuery, array $filters, ?string $channel = null): array
|
||||
{
|
||||
if ($this->dailySummarySupports($filters)) {
|
||||
if ($channel === null && $this->dailySummarySupports($filters)) {
|
||||
$summary = $this->dailySummary($filters);
|
||||
if ($summary !== null) {
|
||||
return $summary;
|
||||
@ -210,6 +252,13 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
}
|
||||
}
|
||||
|
||||
private function applyChannel(Builder $query, ?string $channel): void
|
||||
{
|
||||
if ($channel !== null && $channel !== '') {
|
||||
$query->where('b.channel', $channel);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<int,object> $rows @return array<string,string> */
|
||||
private function shopNames(array $rows): array
|
||||
{
|
||||
@ -269,6 +318,54 @@ class StoreReceivingAlipayBillMirrorRepository
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function mapRowFor(object $row, ?string $shopName, string $mapping): array
|
||||
{
|
||||
if ($mapping !== 'douyin') {
|
||||
return $this->mapRow($row, $shopName);
|
||||
}
|
||||
|
||||
$amount = abs((float) ($row->amount ?? 0));
|
||||
$income = (string) ($row->inout_type ?? '') === '1' ? $amount : 0.0;
|
||||
$expense = (string) ($row->inout_type ?? '') === '-1' ? $amount : 0.0;
|
||||
$direction = $this->directionName($row->inout_type);
|
||||
|
||||
return [
|
||||
'id' => (string) $row->Id,
|
||||
'record_code' => (string) $row->Id,
|
||||
'store_name' => $shopName,
|
||||
'finance_remark' => null,
|
||||
'order_no' => $row->so_id ?: $row->p_auto_id,
|
||||
'amount' => $amount,
|
||||
'custom_remark' => null,
|
||||
'bill_item' => $row->fee_name,
|
||||
'occurred_at' => $this->dateTime($row->fee_time),
|
||||
'income' => $income,
|
||||
'expense' => $expense,
|
||||
'balance' => $income - $expense,
|
||||
'remark' => null,
|
||||
'modifier' => '聚水潭',
|
||||
'created_at' => $this->dateTime($row->createTime),
|
||||
'updated_at' => $this->dateTime($row->updateTime),
|
||||
'payload' => [
|
||||
'merchant_name' => $shopName,
|
||||
'move_serial_no' => $row->auto_id ?: $row->primary_key,
|
||||
'order_no_2' => $row->p_auto_id ?: $row->so_id,
|
||||
'online_order_no' => $row->so_id,
|
||||
'sub_order_no' => $row->shop_i_id,
|
||||
'goods_id' => $row->sku_id ?: $row->shop_sku_id,
|
||||
'account_type' => $direction,
|
||||
'move_summary' => $row->fee_name,
|
||||
'order_placed_at' => $this->dateTime($row->createTime),
|
||||
'business_type' => $row->combine_type,
|
||||
'settle_account' => $row->channel,
|
||||
'move_scene' => '头条放心购',
|
||||
'talent_id' => null,
|
||||
'talent_name' => null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function directionCode(string $value): string
|
||||
{
|
||||
return match ($value) {
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
/** 店铺收款账单·抖音账户(头条放心购)只读共享业务镜像。 */
|
||||
class StoreReceivingDouyinBillMirrorRepository
|
||||
{
|
||||
public const SOURCE_CHANNEL = 'TouTiaoFXGZJ';
|
||||
|
||||
public function __construct(
|
||||
private readonly StoreReceivingAlipayBillMirrorRepository $source,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array
|
||||
{
|
||||
return $this->source->paginateChannel(
|
||||
$filters,
|
||||
$sort,
|
||||
$direction,
|
||||
$page,
|
||||
$perPage,
|
||||
self::SOURCE_CHANNEL,
|
||||
'douyin',
|
||||
);
|
||||
}
|
||||
|
||||
/** @return iterable<int,array<string,mixed>> */
|
||||
public function exportRows(array $filters, string $sort = 'occurred_at', string $direction = 'asc'): iterable
|
||||
{
|
||||
return $this->source->exportChannelRows(
|
||||
$filters,
|
||||
$sort,
|
||||
$direction,
|
||||
self::SOURCE_CHANNEL,
|
||||
'douyin',
|
||||
);
|
||||
}
|
||||
}
|
||||
5
resources/assets/js/views/business/pages.js
vendored
5
resources/assets/js/views/business/pages.js
vendored
@ -5,8 +5,9 @@
|
||||
*
|
||||
* 店铺收款账户账单(store-receiving-bills)下各 tab:
|
||||
* - taobao-alipay → 独立实现 TaobaoAlipayBills(经营分析副本共享镜像)
|
||||
* - taobao-wechat / pinduoduo / jd-wallet / jd-transactions / douyin / dewu
|
||||
* → 通用实现 AccountBills(表 ec_account_bills,按 bill_type 区分,列配置由后端下发)
|
||||
* - taobao-wechat / pinduoduo / jd-wallet / jd-transactions / dewu
|
||||
* → 通用实现 AccountBills(表 ec_account_bills,按 bill_type 区分)
|
||||
* - douyin → AccountBills 展示共享原始账单镜像中的头条放心购渠道
|
||||
*/
|
||||
|
||||
require('./../../bootstrap');
|
||||
|
||||
@ -0,0 +1,708 @@
|
||||
@once
|
||||
<style>
|
||||
/* Shared finance table contract: one compact surface and one scroll owner. */
|
||||
.finance-table-surface,
|
||||
.directory-table-page,
|
||||
.business-content-section {
|
||||
color: var(--vip-content-ink, #171717);
|
||||
font-family: "JetBrains Mono", "Microsoft YaHei", "PingFang SC", monospace;
|
||||
}
|
||||
|
||||
.business-content-section {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--vip-surface, #ffffff);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.business-tab-bar {
|
||||
min-height: 3rem;
|
||||
gap: 1.25rem;
|
||||
padding: 0 1rem;
|
||||
border-color: var(--vip-border, #d8d2c7);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--vip-surface, #ffffff);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.business-tab-link {
|
||||
min-height: 3rem;
|
||||
gap: 0.4rem;
|
||||
color: var(--vip-content-muted, #595959);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.business-tab-link:hover,
|
||||
.business-tab-link.is-active {
|
||||
color: var(--vip-content-accent-dark, #856632);
|
||||
}
|
||||
|
||||
.business-page--marketing-account-bills .business-tab-bar {
|
||||
min-height: 3rem;
|
||||
gap: 1.25rem;
|
||||
padding: 0 1rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.business-page--marketing-account-bills .business-tab-link {
|
||||
min-height: 3rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.business-page--marketing-account-bills .business-content-section {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.finance-table-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--vip-content-bg, #f8f6f1);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.finance-table-controls__button,
|
||||
.finance-table-controls select {
|
||||
display: inline-flex;
|
||||
min-height: 2.125rem;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--vip-surface, #ffffff);
|
||||
color: var(--vip-content-ink, #171717);
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.finance-table-controls__button {
|
||||
padding: 0 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.finance-table-controls__button:hover,
|
||||
.finance-table-controls__button:focus-visible,
|
||||
.finance-table-controls select:focus-visible {
|
||||
border-color: var(--vip-content-accent-dark, #856632);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.2);
|
||||
}
|
||||
|
||||
.finance-table-controls__button--primary {
|
||||
border-color: var(--vip-content-accent-dark, #856632);
|
||||
background: var(--vip-content-accent-dark, #856632);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.finance-table-controls select {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.finance-table-controls__mode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--vip-content-muted, #595959);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.finance-table-controls__mode select { min-width: 9.5rem; }
|
||||
|
||||
.finance-table-controls [data-directory-export],
|
||||
.finance-table-controls [data-directory-export-all],
|
||||
.finance-table-controls [data-finance-server-export] {
|
||||
display: inline-flex;
|
||||
min-height: 2.125rem;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0 0.75rem;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--vip-surface, #ffffff);
|
||||
color: var(--vip-content-ink, #171717);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.finance-table-controls [data-directory-export-all],
|
||||
.finance-table-controls [data-finance-server-export] { border-color: var(--vip-content-accent-dark, #856632); }
|
||||
|
||||
.finance-table-controls [data-directory-export]:hover,
|
||||
.finance-table-controls [data-directory-export-all]:hover,
|
||||
.finance-table-controls [data-finance-server-export]:hover { background: var(--vip-content-accent-soft, #f7f0de); }
|
||||
|
||||
.finance-table-controls .is-column-hidden { display: none !important; }
|
||||
|
||||
.finance-table-field-picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.finance-table-field-picker__panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.35rem);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(10rem, 1fr));
|
||||
gap: 0.25rem 0.75rem;
|
||||
min-width: 25rem;
|
||||
max-height: min(24rem, 60vh);
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--vip-surface, #ffffff);
|
||||
box-shadow: 0 4px 8px rgba(23, 23, 23, 0.1);
|
||||
}
|
||||
|
||||
.finance-table-field-picker__panel[hidden] { display: none; }
|
||||
|
||||
.finance-table-field-picker__option {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 1.75rem;
|
||||
color: var(--vip-content-ink, #171717);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.finance-table-field-picker__option.is-dragging { opacity: 0.45; }
|
||||
.finance-table-field-picker__option input { flex: 0 0 auto; accent-color: var(--vip-content-accent, #b9954f); }
|
||||
.finance-table-field-picker__option span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.finance-table-field-picker__actions { display: flex; grid-column: 1 / -1; gap: 0.35rem; padding-top: 0.35rem; border-top: 1px solid var(--vip-border, #d8d2c7); }
|
||||
.finance-table-field-picker__actions button { min-height: 1.75rem; padding: 0 0.5rem; border: 1px solid var(--vip-border, #d8d2c7); border-radius: 0.35rem; background: #fff; color: var(--vip-content-accent-dark, #856632); cursor: pointer; font: inherit; }
|
||||
|
||||
.finance-table-scroll-owner,
|
||||
.directory-table-scroll,
|
||||
.upstream-data-table-scroll,
|
||||
.bill-table-wrapper,
|
||||
.tb-scroll,
|
||||
.ab-scroll,
|
||||
.marketing-bills-table-wrapper,
|
||||
.jd-wallet-table-wrap,
|
||||
.alibaba-bill-table-wrapper,
|
||||
.zhongcai-table-wrapper {
|
||||
max-height: calc(100vh - 21rem);
|
||||
overflow: auto;
|
||||
scrollbar-color: var(--vip-content-accent, #b9954f) #efede8;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
|
||||
[data-finance-table-ready] thead th,
|
||||
.directory-table thead th,
|
||||
.upstream-data-table thead th,
|
||||
.bill-table thead th,
|
||||
.tb-table thead th,
|
||||
.ab-table thead th,
|
||||
.marketing-bills-table thead th,
|
||||
.jd-wallet-table thead th,
|
||||
.alibaba-bill-table thead th,
|
||||
.zhongcai-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 4;
|
||||
background: var(--vip-content-accent-soft, #f7f0de);
|
||||
color: var(--vip-content-ink, #171717);
|
||||
}
|
||||
|
||||
.tb-table thead .tb-group th,
|
||||
.ab-table thead .ab-group th {
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.tb-table thead .tb-col th,
|
||||
.ab-table thead .ab-col th { top: 2.25rem; z-index: 4; background: #fffdf8; }
|
||||
|
||||
[data-finance-fixed="true"] {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 6;
|
||||
background: var(--vip-surface, #ffffff);
|
||||
}
|
||||
|
||||
thead [data-finance-fixed="true"] { background: var(--vip-content-accent-soft, #f7f0de); }
|
||||
|
||||
[data-finance-column-key].is-column-hidden { display: none !important; }
|
||||
|
||||
.finance-table-controls [data-finance-server-export][disabled],
|
||||
.finance-table-controls [data-directory-export][disabled],
|
||||
.finance-table-controls [data-directory-export-all][disabled] { opacity: 0.55; cursor: wait; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.business-content-section { padding: 0.75rem; }
|
||||
.finance-table-field-picker__panel { right: auto; left: 0; min-width: min(25rem, calc(100vw - 2rem)); grid-template-columns: minmax(0, 1fr); }
|
||||
.finance-table-controls__button, .finance-table-controls select { flex: 1 1 9rem; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@php
|
||||
$financeTableContext = [
|
||||
'user' => auth()->id() ?? 'guest',
|
||||
'section' => $section['slug'] ?? '',
|
||||
'page' => $page['slug'] ?? '',
|
||||
'tab' => $activeTab['slug'] ?? '',
|
||||
];
|
||||
@endphp
|
||||
<script>
|
||||
(() => {
|
||||
const context = @json($financeTableContext);
|
||||
window.__CAIWU_FINANCE_CONTEXT = context;
|
||||
|
||||
const selector = [
|
||||
'[data-finance-table]', '.directory-table', '.upstream-data-table', '.biz-table',
|
||||
'.business-table', '.after-sales-refund-table', '.refund-data-table', '.small-payment-table',
|
||||
'.tb-table', '.ab-table', '.marketing-bills-table', '.bill-table', '.biz-table',
|
||||
'.jd-wallet-table', '.alibaba-bill-table', '.zhongcai-table', '.marketing-balance-table',
|
||||
'.installation-detail-table', '.courier-information-table', '.edge-banding-table',
|
||||
'.agreement-table', '.quote-table', '.recon-table', '.sm-table', '.ding-payable-table',
|
||||
].join(',');
|
||||
const tableStates = new WeakMap();
|
||||
const tableNames = new WeakSet();
|
||||
const routeContext = () => {
|
||||
const parts = window.location.pathname.split('/').filter(Boolean);
|
||||
const workspaceIndex = parts.indexOf('workspace');
|
||||
return workspaceIndex < 0 ? context : {
|
||||
user: context.user,
|
||||
section: parts[workspaceIndex + 1] || context.section,
|
||||
page: parts[workspaceIndex + 2] || context.page,
|
||||
tab: parts[workspaceIndex + 3] || '',
|
||||
};
|
||||
};
|
||||
const keyFor = (table) => {
|
||||
if (table.dataset.financeTableKey) return table.dataset.financeTableKey;
|
||||
const currentContext = routeContext();
|
||||
const parent = table.closest('[data-directory-page], [data-refund-page], [data-finance-table-surface], .business-content-section') || table.parentElement;
|
||||
const name = table.getAttribute('aria-label') || table.dataset.financeTableName || table.className || 'table';
|
||||
const siblings = parent ? [...parent.querySelectorAll(selector)].filter((candidate) => candidate.matches(selector)) : [table];
|
||||
const index = Math.max(0, siblings.indexOf(table));
|
||||
const scope = parent?.dataset.directoryPage || parent?.dataset.refundPage || name;
|
||||
table.dataset.financeTableKey = `${currentContext.user}:${currentContext.section}:${currentContext.page}:${currentContext.tab}:${scope}:${index}`;
|
||||
return table.dataset.financeTableKey;
|
||||
};
|
||||
const storageKey = (table, suffix) => `caiwu.finance.${keyFor(table)}.${suffix}`;
|
||||
const slug = (text) => String(text || '').trim().toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, '_').replace(/^_|_$/g, '') || 'column';
|
||||
const csvText = (value) => {
|
||||
const text = String(value ?? '').trim();
|
||||
if (/^\d{12,}$/.test(text)) return `="${text}"`;
|
||||
return /^[=+\-@]/u.test(text) ? `'${text}` : text;
|
||||
};
|
||||
const csvEscape = (value) => `"${csvText(value).replaceAll('"', '""')}"`;
|
||||
const cellText = (cell) => (cell?.querySelector('[data-password-status]') || cell)?.textContent.trim() || '';
|
||||
const downloadCsv = (filename, lines) => {
|
||||
const blob = new Blob([`\ufeff${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
};
|
||||
|
||||
function leafHeaders(table) {
|
||||
const rows = [...table.tHead?.rows || []];
|
||||
if (!rows.length) return [];
|
||||
return rows.reduce((best, row) => row.cells.length > best.length ? [...row.cells] : best, []);
|
||||
}
|
||||
|
||||
function headerLeafKeys(table) {
|
||||
const rows = [...table.tHead?.rows || []];
|
||||
if (rows.length < 2) return leafHeaders(table).map((cell) => cell.dataset.financeColumnKey);
|
||||
const leafRow = rows[rows.length - 1];
|
||||
return [...leafRow.cells].map((cell) => cell.dataset.financeColumnKey).filter(Boolean);
|
||||
}
|
||||
|
||||
function prepareColumns(table) {
|
||||
const headers = leafHeaders(table);
|
||||
const headerRows = [...table.tHead?.rows || []];
|
||||
const scrollOwner = table.closest('.finance-table-scroll-owner, .directory-table-scroll, .upstream-data-table-scroll, .bill-table-wrapper, .tb-scroll, .ab-scroll, .marketing-bills-table-wrapper, .jd-wallet-table-wrap, .alibaba-bill-table-wrapper, .zhongcai-table-wrapper') || table.parentElement;
|
||||
scrollOwner?.classList.add('finance-table-scroll-owner');
|
||||
headers.forEach((cell, index) => {
|
||||
if (!cell.dataset.financeColumnKey) {
|
||||
cell.dataset.financeColumnKey = cell.dataset.columnKey || cell.dataset.field || cell.dataset.exportField || `${slug(cell.textContent)}_${index}`;
|
||||
}
|
||||
cell.dataset.financeColumnIndex = String(index);
|
||||
if (index === 0 && (cell.querySelector('input[type="checkbox"]') || ['#', '序号'].includes(cell.textContent.trim()))) cell.dataset.financeFixed = 'true';
|
||||
});
|
||||
const keys = headers.map((cell) => cell.dataset.financeColumnKey);
|
||||
const leafRow = headerRows[headerRows.length - 1];
|
||||
if (leafRow) {
|
||||
[...leafRow.cells].forEach((cell, index) => {
|
||||
cell.dataset.financeColumnKey = keys[index];
|
||||
cell.dataset.financeColumnIndex = String(index);
|
||||
if (headers[index]?.dataset.financeFixed === 'true') cell.dataset.financeFixed = 'true';
|
||||
});
|
||||
}
|
||||
const groupRows = headerRows.slice(0, -1).map((row) => {
|
||||
let offset = 0;
|
||||
return [...row.cells].map((cell, groupIndex) => {
|
||||
const span = Math.max(Number(cell.colSpan || 1), 1);
|
||||
const group = { index: groupIndex, label: cell.textContent.trim(), span, template: cell.cloneNode(true) };
|
||||
for (let i = offset; i < Math.min(offset + span, keys.length); i += 1) {
|
||||
headers[i].dataset.financeGroup = String(groupIndex);
|
||||
}
|
||||
offset += span;
|
||||
cell.dataset.financeGroupHeader = 'true';
|
||||
return group;
|
||||
});
|
||||
});
|
||||
table.querySelectorAll('tbody tr').forEach((row) => {
|
||||
if (row.classList.contains('is-empty') || row.children.length !== keys.length) return;
|
||||
[...row.children].forEach((cell, index) => {
|
||||
cell.dataset.financeColumnKey = cell.dataset.financeColumnKey || keys[index];
|
||||
if (headers[index]?.dataset.financeFixed === 'true') cell.dataset.financeFixed = 'true';
|
||||
});
|
||||
});
|
||||
tableStates.set(table, { headers, keys, leafRow, groupRows });
|
||||
table.dataset.financeTableReady = 'true';
|
||||
window.requestAnimationFrame(() => applyStickyOffsets(table));
|
||||
return { headers, keys };
|
||||
}
|
||||
|
||||
function applyStickyOffsets(table) {
|
||||
let offset = 0;
|
||||
[...table.tHead?.rows || []].forEach((row) => {
|
||||
[...row.cells].forEach((cell) => { cell.style.top = `${offset}px`; });
|
||||
offset += row.getBoundingClientRect().height || 36;
|
||||
});
|
||||
}
|
||||
|
||||
function reorderCells(table, order) {
|
||||
const byKey = (row) => new Map([...row.children].map((cell) => [cell.dataset.financeColumnKey, cell]));
|
||||
const rows = [...table.tHead?.rows || []];
|
||||
const state = tableStates.get(table);
|
||||
const leafRow = rows[rows.length - 1];
|
||||
if (leafRow && leafRow.cells.length === order.length) {
|
||||
const map = byKey(leafRow);
|
||||
order.forEach((key) => { const cell = map.get(key); if (cell) leafRow.appendChild(cell); });
|
||||
}
|
||||
rows.slice(0, -1).forEach((row, rowIndex) => {
|
||||
const definitions = state?.groupRows?.[rowIndex];
|
||||
if (!definitions?.length) return;
|
||||
const byGroup = new Map(definitions.map((group) => [String(group.index), group]));
|
||||
const runs = [];
|
||||
order.forEach((key) => {
|
||||
const group = String(state.headers.find((header) => header.dataset.financeColumnKey === key)?.dataset.financeGroup ?? '');
|
||||
const previous = runs[runs.length - 1];
|
||||
if (previous && previous.group === group) previous.span += 1;
|
||||
else runs.push({ group, span: 1 });
|
||||
});
|
||||
row.replaceChildren(...runs.map((run) => {
|
||||
const definition = byGroup.get(run.group);
|
||||
const cell = definition?.template?.cloneNode(true) || document.createElement('th');
|
||||
cell.colSpan = run.span;
|
||||
cell.dataset.financeGroupHeader = 'true';
|
||||
return cell;
|
||||
}));
|
||||
});
|
||||
table.querySelectorAll('tbody tr').forEach((row) => {
|
||||
if (row.classList.contains('is-empty') || row.children.length !== order.length) return;
|
||||
const map = byKey(row);
|
||||
order.forEach((key) => { const cell = map.get(key); if (cell) row.appendChild(cell); });
|
||||
});
|
||||
window.requestAnimationFrame(() => applyStickyOffsets(table));
|
||||
}
|
||||
|
||||
function setVisibility(table, checks, save = true) {
|
||||
const selected = new Set(checks.filter((check) => check.checked).map((check) => check.value));
|
||||
table.dataset.columnsReady = 'true';
|
||||
table.querySelectorAll('[data-finance-column-key]').forEach((cell) => {
|
||||
if (cell.dataset.financeFixed === 'true') return;
|
||||
cell.classList.toggle('is-column-hidden', !selected.has(cell.dataset.financeColumnKey));
|
||||
});
|
||||
const state = tableStates.get(table);
|
||||
if (state?.groupRows?.length) {
|
||||
const order = [...state.leafRow?.cells || []].map((cell) => cell.dataset.financeColumnKey).filter(Boolean);
|
||||
state.groupRows.forEach((definitions, rowIndex) => {
|
||||
const row = table.tHead?.rows?.[rowIndex];
|
||||
if (!row) return;
|
||||
const runs = [];
|
||||
order.forEach((key) => {
|
||||
const header = state.headers.find((cell) => cell.dataset.financeColumnKey === key);
|
||||
if (!header || (!selected.has(key) && header.dataset.financeFixed !== 'true')) return;
|
||||
const group = String(header.dataset.financeGroup ?? '');
|
||||
const previous = runs[runs.length - 1];
|
||||
if (previous && previous.group === group) previous.span += 1;
|
||||
else runs.push({ group, span: 1 });
|
||||
});
|
||||
const byGroup = new Map(definitions.map((definition) => [String(definition.index), definition]));
|
||||
row.replaceChildren(...runs.map((run) => {
|
||||
const template = byGroup.get(run.group)?.template;
|
||||
const cell = template?.cloneNode(true) || document.createElement('th');
|
||||
cell.colSpan = run.span;
|
||||
cell.dataset.financeGroupHeader = 'true';
|
||||
return cell;
|
||||
}));
|
||||
});
|
||||
}
|
||||
window.requestAnimationFrame(() => applyStickyOffsets(table));
|
||||
if (save) {
|
||||
try { localStorage.setItem(storageKey(table, 'visibility'), JSON.stringify([...selected])); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function createPicker(table, headers, host) {
|
||||
let picker = host.querySelector('[data-finance-field-picker], [data-directory-field-picker]');
|
||||
const isLegacyDirectoryPicker = picker?.hasAttribute('data-directory-field-picker') === true;
|
||||
if (!picker) {
|
||||
picker = document.createElement('div');
|
||||
picker.className = 'finance-table-field-picker';
|
||||
picker.dataset.financeFieldPicker = 'true';
|
||||
picker.innerHTML = '<button type="button" class="finance-table-controls__button" aria-expanded="false">列设置</button><div class="finance-table-field-picker__panel" role="group" aria-label="选择展示字段" hidden></div>';
|
||||
host.prepend(picker);
|
||||
}
|
||||
picker.dataset.financeFieldPicker = 'true';
|
||||
picker.classList.add('finance-table-field-picker');
|
||||
const toggle = picker.querySelector('button');
|
||||
const panel = picker.querySelector('.finance-table-field-picker__panel, .directory-field-picker__panel');
|
||||
panel.classList.add('finance-table-field-picker__panel');
|
||||
let checks = [...panel.querySelectorAll('input[data-finance-field-check], input[data-directory-field-check]')];
|
||||
if (!checks.length) {
|
||||
headers.forEach((header) => {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'finance-table-field-picker__option';
|
||||
label.draggable = true;
|
||||
const key = header.dataset.financeColumnKey;
|
||||
const fixed = header.dataset.financeFixed === 'true';
|
||||
label.dataset.financeOptionKey = key;
|
||||
label.innerHTML = `<input type="checkbox" value="${key.replaceAll('"', '"')}" data-finance-field-check ${fixed ? 'checked disabled' : 'checked'}><span>${header.textContent.trim() || '未命名字段'}</span>`;
|
||||
panel.appendChild(label);
|
||||
});
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'finance-table-field-picker__actions';
|
||||
actions.innerHTML = '<button type="button" data-finance-all>全选</button><button type="button" data-finance-none>全不选</button><button type="button" data-finance-reset>恢复默认</button>';
|
||||
panel.appendChild(actions);
|
||||
}
|
||||
if (!panel.querySelector('[data-finance-all]')) {
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'finance-table-field-picker__actions';
|
||||
actions.innerHTML = '<button type="button" data-finance-all>全选</button><button type="button" data-finance-none>全不选</button><button type="button" data-finance-reset>恢复默认</button>';
|
||||
panel.appendChild(actions);
|
||||
}
|
||||
checks = [...panel.querySelectorAll('input[data-finance-field-check], input[data-directory-field-check]')];
|
||||
const defaults = headers.map((header) => header.dataset.financeColumnKey);
|
||||
const fixedKeys = headers.filter((header) => header.dataset.financeFixed === 'true').map((header) => header.dataset.financeColumnKey);
|
||||
const defaultVisible = new Set(checks.filter((check) => check.defaultChecked || check.disabled).map((check) => check.value));
|
||||
const applyOrder = (order, save = true) => {
|
||||
const valid = fixedKeys.concat(order.filter((key) => defaults.includes(key) && !fixedKeys.includes(key)));
|
||||
defaults.forEach((key) => { if (!valid.includes(key)) valid.push(key); });
|
||||
reorderCells(table, valid);
|
||||
[...panel.querySelectorAll('[data-finance-option-key], .directory-field-picker__option')].sort((a, b) => valid.indexOf(a.dataset.financeOptionKey || a.querySelector('input')?.value) - valid.indexOf(b.dataset.financeOptionKey || b.querySelector('input')?.value)).forEach((option) => panel.insertBefore(option, panel.querySelector('.finance-table-field-picker__actions')));
|
||||
if (save) localStorage.setItem(storageKey(table, 'order'), JSON.stringify(valid));
|
||||
};
|
||||
try {
|
||||
const savedVisibility = JSON.parse(localStorage.getItem(storageKey(table, 'visibility')) || 'null');
|
||||
if (Array.isArray(savedVisibility)) checks.forEach((check) => { if (!check.disabled) check.checked = savedVisibility.includes(check.value); });
|
||||
const savedOrder = JSON.parse(localStorage.getItem(storageKey(table, 'order')) || 'null');
|
||||
applyOrder(Array.isArray(savedOrder) ? savedOrder : defaults, false);
|
||||
} catch (_) {}
|
||||
setVisibility(table, checks, false);
|
||||
if (!isLegacyDirectoryPicker) {
|
||||
toggle.addEventListener('click', () => { const open = panel.hidden; panel.hidden = !open; toggle.setAttribute('aria-expanded', String(open)); });
|
||||
}
|
||||
checks.forEach((check) => check.addEventListener('change', () => setVisibility(table, checks)));
|
||||
panel.querySelector('[data-finance-all]')?.addEventListener('click', () => { checks.forEach((check) => { if (!check.disabled) check.checked = true; }); setVisibility(table, checks); });
|
||||
panel.querySelector('[data-finance-none]')?.addEventListener('click', () => { checks.forEach((check) => { if (!check.disabled) check.checked = false; }); setVisibility(table, checks); });
|
||||
panel.querySelector('[data-finance-reset]')?.addEventListener('click', () => { checks.forEach((check) => { if (!check.disabled) check.checked = defaultVisible.has(check.value); }); applyOrder(defaults); setVisibility(table, checks); });
|
||||
let dragging = null;
|
||||
panel.querySelectorAll('[data-finance-option-key], .directory-field-picker__option').forEach((option) => {
|
||||
option.dataset.financeOptionKey = option.dataset.financeOptionKey || option.querySelector('input')?.value;
|
||||
if (fixedKeys.includes(option.dataset.financeOptionKey)) {
|
||||
option.draggable = false;
|
||||
return;
|
||||
}
|
||||
option.draggable = true;
|
||||
option.addEventListener('dragstart', () => { dragging = option; option.classList.add('is-dragging'); });
|
||||
option.addEventListener('dragend', () => { option.classList.remove('is-dragging'); dragging = null; });
|
||||
option.addEventListener('dragover', (event) => { event.preventDefault(); if (dragging && dragging !== option) panel.insertBefore(dragging, option); });
|
||||
option.addEventListener('drop', () => { const order = [...panel.querySelectorAll('[data-finance-option-key]')].map((item) => item.dataset.financeOptionKey); applyOrder(order); });
|
||||
});
|
||||
return picker;
|
||||
}
|
||||
|
||||
function ensureControls(table, headers) {
|
||||
const existingDirectoryTools = table.closest('[data-directory-page]')?.querySelector('[data-directory-tools]');
|
||||
const root = table.closest('.upstream-data-table-panel, .directory-table-page, .tb-panel, .ab-panel, .marketing-bills-table-wrapper, .bill-table-wrapper, .jd-wallet-table-wrap, .alibaba-bill-table-wrapper, .zhongcai-table-wrapper') || table.parentElement;
|
||||
if (!root) return;
|
||||
let controls = existingDirectoryTools || root.querySelector('[data-finance-controls]');
|
||||
if (!controls) {
|
||||
controls = document.createElement('div');
|
||||
controls.className = 'finance-table-controls';
|
||||
controls.dataset.financeControls = 'true';
|
||||
root.insertBefore(controls, root.firstChild);
|
||||
}
|
||||
createPicker(table, headers, controls);
|
||||
if (existingDirectoryTools) {
|
||||
controls.dataset.financeExportEnhanced = 'true';
|
||||
const currentPage = controls.querySelector('[data-directory-export]');
|
||||
const allRows = controls.querySelector('[data-directory-export-all]');
|
||||
if (currentPage) currentPage.textContent = '当前页·显示列';
|
||||
if (allRows) allRows.textContent = '全部数据·显示列';
|
||||
const ensureDirectoryExport = (selector, label, allRows) => {
|
||||
const button = controls.querySelector(selector) || document.createElement('button');
|
||||
if (!button.parentElement) controls.appendChild(button);
|
||||
button.type = 'button';
|
||||
button.dataset.financeExportAllFields = 'true';
|
||||
if (allRows) button.dataset.directoryExportAll = 'true';
|
||||
button.textContent = label;
|
||||
return button;
|
||||
};
|
||||
ensureDirectoryExport('[data-finance-export-all-fields]:not([data-directory-export-all])', '当前页·全部字段', false);
|
||||
ensureDirectoryExport('[data-finance-export-all-fields][data-directory-export-all]', '全部数据·全部字段', true);
|
||||
}
|
||||
if (table.dataset.financeExportBase && !controls.querySelector('[data-finance-server-export]')) {
|
||||
const addServerExport = (label, allColumns) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.dataset.financeServerExport = allColumns ? 'all' : 'visible';
|
||||
button.textContent = label;
|
||||
controls.appendChild(button);
|
||||
};
|
||||
addServerExport('导出当前显示列', false);
|
||||
addServerExport('导出全部可导出字段', true);
|
||||
}
|
||||
}
|
||||
|
||||
function tableRows(table, includeHiddenRows, query) {
|
||||
const normalized = String(query || '').trim().toLocaleLowerCase();
|
||||
return [...table.querySelectorAll('tbody tr:not(.is-empty)')].filter((row) => {
|
||||
if (!includeHiddenRows && row.hidden) return false;
|
||||
return !normalized || row.textContent.toLocaleLowerCase().includes(normalized);
|
||||
});
|
||||
}
|
||||
|
||||
function orderedCells(row, order, allColumns) {
|
||||
const cells = [...row.children];
|
||||
const byKey = new Map(cells.map((cell) => [cell.dataset.financeColumnKey || cell.dataset.columnKey, cell]));
|
||||
const keys = allColumns ? order : order.filter((key) => !byKey.get(key)?.classList.contains('is-column-hidden'));
|
||||
return keys.map((key) => byKey.get(key)).filter(Boolean);
|
||||
}
|
||||
|
||||
async function exportDirectory(table, button, includeHiddenRows, allColumns) {
|
||||
const page = table.closest('[data-directory-page]');
|
||||
const search = page?.querySelector('[data-directory-search]');
|
||||
const headers = leafHeaders(table);
|
||||
const selected = new Set([...table.querySelectorAll('thead th[data-finance-column-key]')]
|
||||
.filter((cell) => !cell.classList.contains('is-column-hidden'))
|
||||
.map((cell) => cell.dataset.financeColumnKey));
|
||||
const order = headers.map((header) => header.dataset.financeColumnKey);
|
||||
const exportOrder = allColumns ? order : order.filter((key) => selected.has(key));
|
||||
const header = exportOrder.map((key) => csvEscape(headers.find((cell) => cell.dataset.financeColumnKey === key)?.textContent.trim())).join(',');
|
||||
const rows = [];
|
||||
const lastPage = Number(page?.dataset.directoryLastPage || 1);
|
||||
button.disabled = true;
|
||||
const originalLabel = button.textContent;
|
||||
try {
|
||||
if (includeHiddenRows && lastPage > 1) {
|
||||
for (let current = 1; current <= lastPage; current += 1) {
|
||||
button.textContent = `导出中 ${current}/${lastPage}`;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('page', String(current));
|
||||
const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
if (!response.ok) throw new Error(`第 ${current} 页读取失败`);
|
||||
const parsed = new DOMParser().parseFromString(await response.text(), 'text/html');
|
||||
parsed.querySelectorAll('.directory-table tbody tr:not(.is-empty)').forEach((row) => {
|
||||
const cells = [...row.children];
|
||||
const map = new Map(cells.map((cell, index) => [cell.dataset.columnKey || headers[index]?.dataset.financeColumnKey, cell]));
|
||||
if (search?.value && !row.textContent.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase())) return;
|
||||
rows.push(exportOrder.map((key) => csvEscape(cellText(map.get(key)))).join(','));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
tableRows(table, includeHiddenRows, search?.value).forEach((row) => rows.push(orderedCells(row, exportOrder, true).map((cell) => csvEscape(cellText(cell))).join(',')));
|
||||
}
|
||||
downloadCsv(`${page?.dataset.directoryPage || 'directory'}-${includeHiddenRows ? '全部' : '当前页'}-${allColumns ? '全部字段' : '显示列'}.csv`, [header, ...rows]);
|
||||
} catch (error) {
|
||||
window.alert(error.message || '导出失败,请稍后重试');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = originalLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function exportServer(table, button, allColumns) {
|
||||
const headers = leafHeaders(table);
|
||||
const columns = (allColumns ? headers : headers.filter((header) => !header.classList.contains('is-column-hidden')))
|
||||
.map((header) => header.dataset.exportField)
|
||||
.filter(Boolean);
|
||||
if (!columns.length) {
|
||||
window.alert('请至少保留一个可导出的字段。');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(table.dataset.financeExportBase, window.location.origin);
|
||||
url.searchParams.set('columns', columns.join(','));
|
||||
try {
|
||||
const query = JSON.parse(table.dataset.financeExportQuery || '{}');
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined && String(value) !== '') url.searchParams.set(key, String(value));
|
||||
});
|
||||
} catch (_) {
|
||||
// Keep the export usable when an older Vue bundle has no query metadata.
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
const link = document.createElement('a');
|
||||
link.href = url.toString();
|
||||
link.download = '';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => { button.disabled = false; }, 500);
|
||||
}
|
||||
|
||||
function initialise(table) {
|
||||
if (!table || tableNames.has(table)) return;
|
||||
const prepared = prepareColumns(table);
|
||||
if (!prepared.headers.length) return;
|
||||
tableNames.set(table, true);
|
||||
ensureControls(table, prepared.headers);
|
||||
}
|
||||
|
||||
function scan() { document.querySelectorAll(selector).forEach(initialise); }
|
||||
scan();
|
||||
document.addEventListener('DOMContentLoaded', scan, { once: true });
|
||||
const observer = new MutationObserver(() => scan());
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
window.setTimeout(scan, 0);
|
||||
window.setTimeout(scan, 300);
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const serverButton = event.target.closest('[data-finance-server-export]');
|
||||
if (serverButton) {
|
||||
const table = serverButton.closest('.tb-panel, .ab-panel, .finance-table-scroll-owner, .tb-scroll, .ab-scroll')?.querySelector('table[data-finance-export-base]');
|
||||
if (table) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
exportServer(table, serverButton, serverButton.dataset.financeServerExport === 'all');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const button = event.target.closest('[data-directory-export], [data-directory-export-all], [data-finance-export-all-fields]');
|
||||
if (!button || !button.closest('[data-directory-page]')) return;
|
||||
const table = button.closest('[data-directory-page]')?.querySelector('.directory-table');
|
||||
if (!table) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
exportDirectory(table, button, button.hasAttribute('data-directory-export-all'), button.hasAttribute('data-finance-export-all-fields'));
|
||||
}, true);
|
||||
})();
|
||||
</script>
|
||||
@endonce
|
||||
@ -1071,6 +1071,8 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@include('business.pages.partials.finance-table-interactions')
|
||||
|
||||
@if ($isMarketingAccountBillsPage)
|
||||
@push('scripts_start')
|
||||
<script src="{{ asset('public/js/business/marketing-account-bills.min.js') }}?id={{ md5_file(public_path('js/business/marketing-account-bills.min.js')) }}"></script>
|
||||
|
||||
@ -25,6 +25,8 @@ Route::get('ecommerce-finance/alipay-bills/{bill}', [AlipayBills::class, 'show']
|
||||
|
||||
Route::get('ecommerce-finance/account-bills/type/{bill_type}', [AccountBills::class, 'index'])
|
||||
->name('ecommerce-finance.account-bills.index');
|
||||
Route::get('ecommerce-finance/account-bills/type/{bill_type}/export', [AccountBills::class, 'export'])
|
||||
->name('ecommerce-finance.account-bills.export');
|
||||
Route::get('ecommerce-finance/account-bills/{bill}', [AccountBills::class, 'show'])
|
||||
->name('ecommerce-finance.account-bills.show');
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ use Modules\EcommerceFinance\View\OrderDataPageData;
|
||||
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class PagesTest extends FeatureTestCase
|
||||
@ -116,6 +117,94 @@ class PagesTest extends FeatureTestCase
|
||||
->assertSee('public/js/business/pages.min.js', false);
|
||||
}
|
||||
|
||||
public function testDouyinAccountBillsEndpointReadsTheFixedTouTiaoChannel(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
|
||||
$repository = Mockery::mock(StoreReceivingDouyinBillMirrorRepository::class);
|
||||
$repository->shouldReceive('paginate')->once()->with(
|
||||
[], 'occurred_at', 'desc', 1, 10,
|
||||
)->andReturn([
|
||||
'current_page' => 1,
|
||||
'last_page' => 1,
|
||||
'per_page' => 10,
|
||||
'total' => 1,
|
||||
'data' => [[
|
||||
'id' => '9001',
|
||||
'record_code' => '9001',
|
||||
'store_name' => '放心购旗舰店',
|
||||
'income' => 88.50,
|
||||
'expense' => 0.0,
|
||||
'payload' => [
|
||||
'settle_account' => 'TouTiaoFXGZJ',
|
||||
'move_scene' => '头条放心购',
|
||||
],
|
||||
]],
|
||||
'summary' => ['income' => 88.50, 'expense' => 0.0, 'balance' => 88.50, 'count' => 1],
|
||||
'sync' => ['status' => 'succeeded', 'last_succeeded_at' => '2026-09-08 09:00:00'],
|
||||
]);
|
||||
$this->app->instance(StoreReceivingDouyinBillMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->withHeader('X-Requested-With', 'XMLHttpRequest')
|
||||
->get(route('ecommerce-finance.account-bills.index', ['bill_type' => 'douyin']))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.payload.move_scene', '头条放心购')
|
||||
->assertJsonPath('data.0.payload.settle_account', 'TouTiaoFXGZJ')
|
||||
->assertJsonPath('columns.title', '抖音账户')
|
||||
->assertJsonPath('summary.count', 1);
|
||||
}
|
||||
|
||||
public function testDouyinAccountBillsExportUsesSelectedColumnOrder(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
|
||||
$repository = Mockery::mock(StoreReceivingDouyinBillMirrorRepository::class);
|
||||
$repository->shouldReceive('exportRows')->once()->with([], 'occurred_at', 'desc')->andReturn([[
|
||||
'record_code' => '9001',
|
||||
'payload' => [
|
||||
'move_scene' => '头条放心购',
|
||||
'settle_account' => 'TouTiaoFXGZJ',
|
||||
],
|
||||
]]);
|
||||
$this->app->instance(StoreReceivingDouyinBillMirrorRepository::class, $repository);
|
||||
|
||||
$response = $this->loginAs()->get(route('ecommerce-finance.account-bills.export', [
|
||||
'bill_type' => 'douyin',
|
||||
'columns' => 'payload.move_scene,record_code,payload.settle_account',
|
||||
]));
|
||||
|
||||
$response->assertOk()
|
||||
->assertHeader('content-type', 'text/csv; charset=UTF-8')
|
||||
->assertDownload('douyin_account_bills.csv');
|
||||
$csv = $response->streamedContent();
|
||||
$this->assertStringContainsString('动账场景,记录编码,结算账户', $csv);
|
||||
$this->assertStringContainsString('头条放心购,9001,TouTiaoFXGZJ', $csv);
|
||||
}
|
||||
|
||||
public function testDouyinAccountBillsReturnsARetryableResponseWhenMirrorIsNotReady(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
|
||||
$repository = Mockery::mock(StoreReceivingDouyinBillMirrorRepository::class);
|
||||
$repository->shouldReceive('paginate')->once()->andThrow(
|
||||
new \RuntimeException('store_receiving_alipay_bill_mirror_not_ready')
|
||||
);
|
||||
$this->app->instance(StoreReceivingDouyinBillMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->withHeader('X-Requested-With', 'XMLHttpRequest')
|
||||
->get(route('ecommerce-finance.account-bills.index', ['bill_type' => 'douyin']))
|
||||
->assertStatus(503)
|
||||
->assertJsonPath('message', '抖音账户账单共享业务镜像正在首次同步,完成后将自动显示。');
|
||||
}
|
||||
|
||||
public function testTaobaoAlipayBillsExportUsesTheMirrorRepository()
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
@ -127,7 +216,7 @@ class PagesTest extends FeatureTestCase
|
||||
static fn (array $filters): bool => ($filters['store_name'] ?? '') === '天猫-经营店'
|
||||
&& ($filters['start'] ?? '') === '2026-08-01'
|
||||
&& ($filters['end'] ?? '') === '2026-08-31'
|
||||
))->andReturn([[
|
||||
), 'occurred_at', 'desc')->andReturn([[
|
||||
'record_code' => '1', 'store_name' => '天猫-经营店', 'alipay_account' => 'CommonBillTGCPay',
|
||||
'bill_item' => '交易佣金', 'account_order_no' => 'KEY-1', 'business_serial_no' => '1001',
|
||||
'merchant_order_no' => 'P-1', 'business_base_order_no' => 'ORDER-1', 'goods_name' => 'SKU-1',
|
||||
|
||||
@ -7,6 +7,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
@ -193,6 +194,34 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
|
||||
$this->assertSame(3, $this->repository->paginate([], 'occurred_at', 'desc', 1, 1)['total']);
|
||||
}
|
||||
|
||||
public function test_douyin_reads_only_toutiao_fangxin_gou_rows_and_maps_flow_fields(): void
|
||||
{
|
||||
$this->markReady();
|
||||
$db = DB::connection('business_mirror');
|
||||
$db->table('ops_jst_shop_info')->insert([
|
||||
'Id' => 10, 'shop_id' => 7, 'shop_name' => '放心购旗舰店',
|
||||
]);
|
||||
$db->table('ops_jst_original_bill_info')->insert([
|
||||
$this->row(1, 7, '2026-08-01 09:00:00', '1', 100.25, 'TouTiaoFXGZJ'),
|
||||
$this->row(2, 7, '2026-08-02 09:00:00', '-1', 20.10, 'CommonBillTGCPay'),
|
||||
]);
|
||||
|
||||
$douyin = new StoreReceivingDouyinBillMirrorRepository($this->repository);
|
||||
$result = $douyin->paginate([], 'occurred_at', 'desc', 1, 10);
|
||||
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame('1', $result['data'][0]['record_code']);
|
||||
$this->assertSame('放心购旗舰店', $result['data'][0]['store_name']);
|
||||
$this->assertSame('TouTiaoFXGZJ', $result['data'][0]['payload']['settle_account']);
|
||||
$this->assertSame('头条放心购', $result['data'][0]['payload']['move_scene']);
|
||||
$this->assertSame(100.25, $result['summary']['income']);
|
||||
$this->assertSame(0.0, $result['summary']['expense']);
|
||||
|
||||
$rows = iterator_to_array($douyin->exportRows([], 'occurred_at', 'asc'));
|
||||
$this->assertCount(1, $rows);
|
||||
$this->assertSame('1', $rows[0]['record_code']);
|
||||
}
|
||||
|
||||
private function markReady(): void
|
||||
{
|
||||
foreach ([StoreReceivingAlipayBillMirrorRepository::DATASET, StoreReceivingAlipayBillMirrorRepository::SHOP_DATASET] as $dataset) {
|
||||
@ -205,10 +234,17 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
private function row(int $id, int $shopId, string $time, string $direction, float $amount): array
|
||||
private function row(
|
||||
int $id,
|
||||
int $shopId,
|
||||
string $time,
|
||||
string $direction,
|
||||
float $amount,
|
||||
string $channel = 'CommonBillTGCPay',
|
||||
): array
|
||||
{
|
||||
return [
|
||||
'Id' => $id, 'shop_id' => $shopId, 'channel' => 'CommonBillTGCPay',
|
||||
'Id' => $id, 'shop_id' => $shopId, 'channel' => $channel,
|
||||
'primary_key' => 'KEY-' . $id, 'fee_name' => '交易佣金', 'inout_type' => $direction,
|
||||
'p_auto_id' => 'P-' . $id, 'shop_i_id' => 'STYLE-' . $id,
|
||||
'shop_sku_id' => 'SHOP-SKU-' . $id, 'sku_id' => 'SKU-' . $id,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user