qiuxiang a08b28199d 新增店铺收款唯品会JX账户页签
店铺收款账户账单新增 vipshop-jx 页签:导航配置、列定义、控制器与前端路由;读取共享账单表 ec_account_bills 中的唯品会JX(JITX)原始账单(店铺/类型/二次备注/销售订单号/含税金额),按金额正负拆出收入与支出,订单日期由销售订单号前 6 位还原(原始账单无日期列)。DB 页签日期统一按 Y-m-d H:i:s 返回,JX 页签汇总口径为收支净额,并补齐单元与功能测试。
2026-09-28 17:51:41 +08:00

479 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Http\Controllers;
use App\Utilities\SidebarPermissions;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\AccountBillColumns;
use Modules\EcommerceFinance\Models\AccountBill;
use Modules\EcommerceFinance\Repositories\JdDailyBillRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingJdWalletBillRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingDewuBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingPinduoduoBillMirrorRepository;
use RuntimeException;
class AccountBills extends Controller
{
/**
* 通用账单列表(店铺收款账户账单 › 各账户类型 tab)。
* 经 admin 路由组(web 会话)暴露,前端带 CSRF 调用。
* 按 bill_type 过滤,返回分页数据 + 汇总 + 该类型的列配置。
*/
public function index(Request $request, string $bill_type)
{
$this->authorizeStoreReceivingBills();
$billType = $bill_type;
if (! AccountBillColumns::has($billType)) {
abort(404, '未知的账户类型。');
}
if (in_array($billType, ['douyin', 'pinduoduo', 'dewu', 'jd-transactions', 'jd-wallet'], true)) {
return $request->boolean('export')
? $this->export($request, $billType)
: $this->mirrorIndex($request, $billType);
}
$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'],
'export' => ['nullable', 'boolean'],
'columns' => ['nullable', 'array', 'max:40'],
'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);
});
}
$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);
if ($request->boolean('export')) {
return $this->exportQuery($request, $query, $billType);
}
$perPage = min(max((int) $request->get('limit', 10), 1), 100);
$rows = $query->paginate($perPage);
// 汇总(分页前)
$summaryRow = (clone $query)->selectRaw(
'COALESCE(SUM(income), 0) as income, ' .
'COALESCE(SUM(expense), 0) as expense, ' .
'COUNT(*) as count'
)->first();
$latestBalance = (clone $query)
->orderByDesc('occurred_at')
->orderByDesc('id')
->value('balance');
$data = $rows->toArray();
$data['data'] = collect($rows->items())
->map(fn (AccountBill $bill): array => $this->billRow($bill))
->all();
$income = (float) ($summaryRow->income ?? 0);
$expense = (float) ($summaryRow->expense ?? 0);
$data['summary'] = [
'income' => $income,
'expense' => $expense,
'count' => (int) ($summaryRow->count ?? 0),
// 唯品会JX账单没有账户余额列,该页签以收支净额作为汇总口径。
'balance' => $billType === 'vipshop-jx'
? round($income - $expense, 2)
: ($latestBalance !== null ? (float) $latestBalance : 0),
];
$data['columns'] = AccountBillColumns::for($billType);
return response()->json($data);
}
public function export(Request $request, string $bill_type)
{
$this->authorizeStoreReceivingBills();
abort_unless(AccountBillColumns::has($bill_type), 404, '未知的账户类型。');
if (in_array($bill_type, ['douyin', 'pinduoduo', 'dewu', 'jd-transactions', 'jd-wallet'], true)) {
return $this->exportMirror($request, $bill_type);
}
$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);
}
public function filterOptions(string $bill_type)
{
$this->authorizeStoreReceivingBills();
if (in_array($bill_type, ['vipshop', 'vipshop-jx'], true)) {
$query = AccountBill::query()
->where('company_id', company_id())
->where('bill_type', $bill_type);
return response()->json([
'shops' => (clone $query)->whereNotNull('store_name')->where('store_name', '<>', '')
->distinct()->orderBy('store_name')->pluck('store_name')->all(),
'bill_items' => (clone $query)->whereNotNull('bill_item')->where('bill_item', '<>', '')
->distinct()->orderBy('bill_item')->pluck('bill_item')->all(),
]);
}
abort_unless(in_array($bill_type, ['douyin', 'pinduoduo', 'dewu', 'jd-transactions', 'jd-wallet'], true), 404, '该账户类型没有可用的筛选选项。');
$label = $this->mirrorLabel($bill_type);
try {
return response()->json(
$this->mirrorRepository($bill_type)->filterOptions()
);
} catch (RuntimeException $exception) {
if (! in_array($exception->getMessage(), [
'store_receiving_alipay_bill_mirror_not_ready',
'store_receiving_bill_filter_options_busy',
'jd_daily_bill_mirror_not_ready',
'jd_wallet_bill_mirror_not_ready',
], true)) {
throw $exception;
}
return response()->json([
'message' => $exception->getMessage() === 'store_receiving_bill_filter_options_busy'
? $label . '账户账单筛选选项正在生成,请稍后重试。'
: $label . '账户账单共享业务镜像正在首次同步,筛选选项稍后可用。',
'shops' => [],
'bill_items' => [],
], 503);
}
}
/** 账单表格按 Y-m-d H:i:s 展示日期,避免 Eloquent 默认序列化成 ISO8601(UTC)。 */
private function billRow(AccountBill $bill): array
{
return collect($bill->toArray())
->map(function (mixed $value, string $field) use ($bill): mixed {
$attribute = $bill->getAttribute($field);
return $attribute instanceof \DateTimeInterface
? $attribute->format('Y-m-d H:i:s')
: $value;
})
->all();
}
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 = [];
}
if ($requested !== []) {
$requested = collect($requested)->unique()->values();
$invalid = $requested->reject(fn (string $field): bool => $available->has($field));
abort_if($invalid->isNotEmpty(), 422, '包含不可导出的字段:' . $invalid->implode('、'));
}
$columns = $requested === []
? $available->values()
: $requested->map(fn (string $field) => $available->get($field))->values();
abort_if($columns->isEmpty(), 422, '至少选择一个可导出的字段。');
return response()->streamDownload(function () use ($query, $columns): void {
$csv = fopen('php://output', 'w');
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, $columns->pluck('label')->all());
foreach ($query->cursor() as $row) {
fputcsv($csv, $columns->map(function (array $column) use ($row): string {
$value = $this->columnValue($row, $column['field']);
return $this->csvValue($value, $column['kind']);
})->all());
}
fclose($csv);
}, $billType . '_account_bills.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
private function columnValue(AccountBill $row, string $field): mixed
{
if (str_starts_with($field, 'payload.')) {
return data_get($row->payload ?? [], substr($field, 8), '');
}
return $row->getAttribute($field) ?? '';
}
private function csvValue(mixed $value, string $kind): string
{
$text = (string) ($value ?? '');
if ($kind === 'num' && preg_match('/^\d{12,}$/', $text)) {
return "\t" . $text;
}
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 mirrorIndex(Request $request, string $billType)
{
$filters = $this->mirrorFilters($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);
$label = $this->mirrorLabel($billType);
try {
$data = $this->mirrorRepository($billType)->paginate(
$filters,
$sort,
$direction,
$page,
$perPage,
);
} catch (RuntimeException $exception) {
if (! in_array($exception->getMessage(), ['store_receiving_alipay_bill_mirror_not_ready', 'jd_daily_bill_mirror_not_ready', 'jd_wallet_bill_mirror_not_ready'], true)) {
throw $exception;
}
return response()->json([
'message' => $label . '账户账单共享业务镜像正在首次同步,完成后将自动显示。',
'data' => [],
'total' => 0,
'current_page' => 1,
'last_page' => 1,
'per_page' => $perPage,
'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0],
'columns' => AccountBillColumns::for($billType),
], 503);
}
$data['columns'] = AccountBillColumns::for($billType);
return response()->json($data);
}
private function exportMirror(Request $request, string $billType)
{
$filters = $this->mirrorFilters($request);
$definition = AccountBillColumns::for($billType);
$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');
$invalid = $requested->reject(static fn (string $field): bool => $columnsByField->has($field));
abort_if($invalid->isNotEmpty(), 422, '包含不可导出的字段:' . $invalid->implode('、'));
$selected = $requested->map(static fn (string $field) => $columnsByField->get($field))->values();
abort_if($selected->isEmpty(), 422, '未选择有效的导出字段。');
}
$sort = $this->sort($request);
$direction = $this->direction($request);
try {
$rows = $this->mirrorRepository($billType)
->exportRows($filters, $sort, $direction);
} catch (RuntimeException $exception) {
if (! in_array($exception->getMessage(), ['store_receiving_alipay_bill_mirror_not_ready', 'jd_daily_bill_mirror_not_ready', 'jd_wallet_bill_mirror_not_ready'], true)) {
throw $exception;
}
return response()->json(['message' => $this->mirrorLabel($billType) . '账户账单共享业务镜像正在首次同步,完成后将自动导出。'], 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);
}, $billType . '_account_bills.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @return array<string,string> */
private function mirrorFilters(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'],
'keyword_match' => ['nullable', 'in:contains,exact-record'],
]);
return array_map(static fn (mixed $value): string => trim((string) $value), $filters);
}
private function mirrorRepository(string $billType): object
{
return match ($billType) {
'jd-transactions' => app(JdDailyBillRepository::class),
'jd-wallet' => app(StoreReceivingJdWalletBillRepository::class),
'pinduoduo' => app(StoreReceivingPinduoduoBillMirrorRepository::class),
'dewu' => app(StoreReceivingDewuBillMirrorRepository::class),
default => app(StoreReceivingDouyinBillMirrorRepository::class),
};
}
private function mirrorLabel(string $billType): string
{
return match ($billType) {
'jd-transactions' => '京东账单',
'jd-wallet' => '京东钱包',
'pinduoduo' => '拼多多',
'dewu' => '得物',
default => '抖音',
};
}
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();
return response()->json($bill);
}
protected function authorizeStoreReceivingBills(): void
{
$section = collect(SidebarPermissions::navigationFor(user()))
->firstWhere('slug', 'billing-transactions');
$page = collect($section['pages'] ?? [])
->firstWhere('slug', 'store-receiving-bills');
abort_if(empty($page), 403, '当前账号没有店铺收款账户账单权限。');
}
}