654 lines
26 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 Modules\EcommerceFinance\Http\Concerns\DecoratesLedgerRemarks;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\AccountBillColumns;
use Modules\EcommerceFinance\JdBillPlatform;
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
{
use DecoratesLedgerRemarks;
/** 走共享业务镜像的账户类型(其余类型读本系统账单表 ec_account_bills)。 */
private const MIRROR_TABS = ['douyin', 'pinduoduo', 'dewu', 'jd-transactions', 'jingxi', 'jd-wallet'];
/**
* 通用账单列表(店铺收款账户账单 › 各账户类型 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, self::MIRROR_TABS, true)) {
return $request->boolean('export')
? $this->export($request, $billType)
: $this->mirrorIndex($request, $billType);
}
$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'],
'keyword' => ['nullable', 'string', 'max:255'],
'export' => ['nullable', 'boolean'],
'columns' => ['nullable', 'array', 'max:40'],
'columns.*' => ['string', 'max:128'],
]);
// 与导出共用同一份筛选(含「总账备注」翻译出的账单项目集合)。
$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';
$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($this->withLedgerRemarks($data, $billType));
}
public function export(Request $request, string $bill_type)
{
$this->authorizeStoreReceivingBills();
// 导出进度:前端带 token(+列表总数)发起下载,导出过程中把已导出行数写进缓存,前端轮询显示。
$exportToken = $this->exportProgressToken($request);
$exportTotal = max(0, (int) $request->get('total', 0));
$this->putExportProgress($exportToken, 0, $exportTotal);
abort_unless(AccountBillColumns::has($bill_type), 404, '未知的账户类型。');
if (in_array($bill_type, self::MIRROR_TABS, true)) {
return $this->exportMirror($request, $bill_type);
}
$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'],
'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();
$ledgerRemarks = $this->ledgerRemarkMap()->remarksForTab($bill_type);
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(),
'ledger_remarks' => $ledgerRemarks,
]);
}
abort_unless(in_array($bill_type, self::MIRROR_TABS, true), 404, '该账户类型没有可用的筛选选项。');
$label = $this->mirrorLabel($bill_type);
try {
return response()->json(
$this->mirrorRepository($bill_type)->filterOptions() + ['ledger_remarks' => $ledgerRemarks]
);
} 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' => [],
'ledger_remarks' => $ledgerRemarks,
], 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, $exportToken, $exportTotal): void {
// 大账期逐行生成较慢:先发文件头并关掉输出缓冲,让浏览器立刻开始下载。
while (ob_get_level() > 0) {
ob_end_flush();
}
$csv = fopen('php://output', 'w');
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, $columns->pluck('label')->all());
flush();
$written = 0;
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());
$written++;
if ($written % 5000 === 0) {
$this->putExportProgress($exportToken, $written, $exportTotal);
}
}
fclose($csv);
$this->putExportProgress($exportToken, $written, $exportTotal, 'done');
}, $billType . '_account_bills.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
'X-Accel-Buffering' => 'no',
]);
}
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);
}
$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);
}
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, $billType);
$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($this->withLedgerRemarks($data, $billType));
}
/** 导出进度查询:前端带 token 轮询,展示「已导出 X / 共 Y 行」。 */
public function exportProgress(Request $request)
{
$token = $this->exportProgressToken($request);
abort_if($token === '', 400, '导出标识无效。');
$state = \Illuminate\Support\Facades\Cache::get(self::exportProgressKey($token));
return response()->json($state ?? ['status' => 'unknown', 'rows' => 0, 'total' => 0], 200, [
'Cache-Control' => 'private, no-store',
]);
}
/** 新建后台导出任务:立刻返回,导出在后台进程里跑,页面到「导出进度」列表里看状态并下载。 */
public function createExportTask(Request $request)
{
$this->authorizeStoreReceivingBills();
$tab = trim((string) $request->input('tab', ''));
$columns = $this->exportColumnsFor($tab);
abort_if($columns === [], 422, '该页签暂不支持后台导出。');
$requested = array_filter(array_map(
static fn (mixed $field): string => trim((string) $field),
(array) $request->input('columns', []),
));
$selected = [];
foreach ($requested as $field) {
if (isset($columns[$field])) {
$selected[] = ['field' => $field, 'label' => $columns[$field]];
}
}
if ($selected === []) {
foreach ($columns as $field => $label) {
$selected[] = ['field' => (string) $field, 'label' => (string) $label];
}
}
$filters = $request->except(['columns', 'tab', '_token']);
$tasks = app(\Modules\EcommerceFinance\Services\AccountBillsExportTasks::class);
$task = $tasks->create(
(int) user()->id,
$tab,
$this->mirrorLabel($tab),
$filters,
$selected,
max(0, (int) $request->input('total', 0)),
);
$tasks->dispatch($task);
return response()->json(['status' => 'ok', 'token' => $task['token']], 200, [
'Cache-Control' => 'private, no-store',
]);
}
/** 导出任务列表(当前用户,最近 50 条)。 */
public function exportTasks()
{
$this->authorizeStoreReceivingBills();
$tasks = app(\Modules\EcommerceFinance\Services\AccountBillsExportTasks::class);
$userId = (int) user()->id;
return response()->json([
'status' => 'ok',
'data' => array_map(static fn (array $task): array => $tasks->present($task), $tasks->all($userId)),
], 200, ['Cache-Control' => 'private, no-store']);
}
/** 下载已完成的导出文件。 */
public function downloadExportTask(Request $request, string $token)
{
$this->authorizeStoreReceivingBills();
$tasks = app(\Modules\EcommerceFinance\Services\AccountBillsExportTasks::class);
$task = $tasks->find((int) user()->id, $token);
abort_if($task === null, 404, '导出任务不存在或已过期。');
abort_unless(($task['status'] ?? '') === 'done', 409, '导出尚未完成,请稍后刷新。');
$path = $tasks->csvPath($task);
abort_unless(is_file($path), 404, '导出文件已过期,请重新导出。');
$name = str_replace(['/', '\\'], '-', (string) ($task['tab_label'] ?? 'account-bills'))
. '_' . substr($token, 0, 8) . '.csv';
return response()->download($path, $name, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/** 取消导出任务:写入取消标记,后台进程检测到后停止并删除半成品文件。 */
public function cancelExportTask(Request $request, string $token)
{
$this->authorizeStoreReceivingBills();
$tasks = app(\Modules\EcommerceFinance\Services\AccountBillsExportTasks::class);
$task = $tasks->find((int) user()->id, $token);
abort_if($task === null, 404, '导出任务不存在或已过期。');
$status = (string) ($task['status'] ?? '');
abort_if(in_array($status, ['done', 'failed', 'cancelled'], true), 409, '该任务已结束,无需取消。');
// 立刻显示「已取消」(排队中的任务没有进程去改状态,必须在这里定稿);
// 运行中的任务会在下一个检查点停下并删掉半成品文件。
$tasks->patch($task, [
'cancel' => true,
'status' => 'cancelled',
'finished_at' => \Modules\EcommerceFinance\Services\AccountBillsExportTasks::beijingNow(),
]);
return response()->json(['status' => 'ok'], 200, ['Cache-Control' => 'private, no-store']);
}
/**
* 该页签可导出的字段:field => label。
*
* @return array<string,string>
*/
private function exportColumnsFor(string $tab): array
{
if ($tab === 'taobao-alipay') {
return AlipayBills::EXPORT_COLUMNS;
}
$definition = AccountBillColumns::for($tab);
$columns = [];
foreach ((array) ($definition['columns'] ?? []) as $column) {
$field = (string) ($column['field'] ?? '');
if ($field !== '') {
$columns[$field] = (string) ($column['label'] ?? $field);
}
}
return $columns;
}
private function exportMirror(Request $request, string $billType)
{
$exportToken = $this->exportProgressToken($request);
$exportTotal = max(0, (int) $request->get('total', 0));
$this->putExportProgress($exportToken, 0, $exportTotal);
$filters = $this->mirrorFilters($request, $billType);
$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();
$rows = $this->ledgerRemarkRows($rows, $billType);
return response()->streamDownload(function () use ($rows, $selected, $headers, $exportToken, $exportTotal): void {
while (ob_get_level() > 0) {
ob_end_flush();
}
$csv = fopen('php://output', 'w');
fputs($csv, "\xEF\xBB\xBF");
fputcsv($csv, $headers);
flush();
$written = 0;
foreach ($rows as $row) {
fputcsv($csv, $selected->map(
fn (array $column): string => $this->exportArrayValue($row, $column)
)->all());
$written++;
if ($written % 5000 === 0) {
$this->putExportProgress($exportToken, $written, $exportTotal);
}
}
fclose($csv);
$this->putExportProgress($exportToken, $written, $exportTotal, 'done');
}, $billType . '_account_bills.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
'X-Accel-Buffering' => 'no',
]);
}
/** @return array<string,string> */
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'],
'keyword' => ['nullable', 'string', 'max:255'],
'keyword_match' => ['nullable', 'in:contains,exact-record'],
]);
$filters = array_map(static fn (mixed $value): string => trim((string) $value), $filters);
return $this->withLedgerRemarkFilter($filters, $billType);
}
private function mirrorRepository(string $billType): object
{
if (JdBillPlatform::isJdBillTab($billType)) {
// 京东账单与京喜账单共用同一个京东日账单仓库,只用平台口径区分数据。
return app(JdDailyBillRepository::class)
->forPlatform(JdBillPlatform::platformForBillType($billType));
}
return match ($billType) {
'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' => '京东账单',
'jingxi' => '京喜账单',
'jd-wallet' => '京东钱包',
'pinduoduo' => '拼多多',
'dewu' => '得物',
'taobao-alipay' => '淘系支付宝账户',
'taobao-wechat' => '淘系微信账户',
'vipshop' => '唯品会MP账户',
'vipshop-jx' => '唯品会JX账户',
'douyin' => '抖音账户',
default => $billType,
};
}
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, '当前账号没有店铺收款账户账单权限。');
}
}