按所选月份上一月末各支付宝账号最后一笔余额汇总期初余额,余额按“期初余额 + 本期收入 - 本期支出”计算;页面新增期初余额卡片并补全余额展示。 > 开工时标题使用 `WIP: <任务名称>` 并先填写“任务占用与边界”。Codex 完成独立合并前自审后补齐其余内容并移除 `WIP:`。 ## 任务占用与边界 - 任务名称: - 实际开发者: - 任务来源/团队分工: - 目标结果: - 预计修改路径: - 将写入的公共契约(路由、Controller 方法、Repository、表/视图、`source_key`、`scope`、权限名、菜单键或共享组件): - 仅复用、明确不修改的公共契约: - 数据源、表/视图和字段: - 上游 `source_key` / `scope`: - 权限名、菜单键或共享组件: - 明确不修改的范围: ## 开工碰撞检查 - [ ] Codex 已执行 `scripts/codex-task-preflight.ps1` - [ ] 已检查最新 `main` 和所有远程任务分支 - [ ] 已搜索主线既有页面、接口、Repository 和数据查询 - [ ] 已把“将写入”和“仅复用、不修改”的公共契约分开;仅复用项不作为单独阻断条件 - [ ] 未发现实际写入重叠;或已在下方写明相关分支、具体重叠项和协调结论 相关分支、重叠项与协调结论: ## 实际改动 - 完成内容: - Codex 实际修改的文件: - 新增或变更的公共契约: - 与原计划的差异: ## 验证证据 - 正常流程: - 空数据/错误输入/无权限场景: - 受影响旧功能: - 自动测试及结果: - 实际页面、接口或只读业务查询结果: ## 风险检查 - [ ] 不涉及金额、权限、审批、数据库迁移、生产配置或数据修复 - [ ] 如涉及上述高风险内容,已在下方写明影响、回滚和所需授权 - [ ] 未提交 `.env`、密码、Token、私钥、真实财务数据、生产日志或构建产物 - [ ] 前端改动符合 `DESIGN.md`;无前端改动则写“不适用” 高风险影响、回滚和授权状态: ## 合并前复查 - [ ] 已重新拉取远程引用并用实际修改范围再次运行碰撞检查 - [ ] PR 不再是 `WIP:`,所有测试结果已经补齐 - [ ] Codex 已重新读取原始需求和验收标准,并逐文件审核 `origin/main...任务分支` 的完整 diff - [ ] Codex 自审确认无越界修改、重复实现、敏感内容、未处理冲突和未验证结果 - [ ] 合并顺序不会覆盖其他未合并任务 - [ ] 已授权当前任务的 Codex 执行 Squash merge、验证 `main` 并清理任务分支 - [ ] 如涉及生产迁移、数据修复、删除、覆盖配置或部署,已另行取得对应高风险授权 Reviewed-on: #881
253 lines
10 KiB
PHP
253 lines
10 KiB
PHP
<?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\Models\AlipayBill;
|
||
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
|
||
use RuntimeException;
|
||
|
||
class AlipayBills extends Controller
|
||
{
|
||
use DecoratesLedgerRemarks;
|
||
public const EXPORT_COLUMNS = [
|
||
'record_code' => '记录编码',
|
||
'store_name' => '原始店铺',
|
||
'ledger_remark' => '总账备注',
|
||
'custom_remark' => '自定义备注',
|
||
'bill_item' => '账单项目',
|
||
'alipay_account' => '支付宝账号',
|
||
'account_order_no' => '账务订单号',
|
||
'business_serial_no' => '业务流水号',
|
||
'merchant_order_no' => '商户订单号',
|
||
'business_base_order_no' => '业务基础订单号',
|
||
'goods_name' => '商品名称',
|
||
'counterparty_account' => '对方账号',
|
||
'occurred_at' => '发生时间',
|
||
'income' => '收入',
|
||
'expense' => '支出',
|
||
'balance' => '余额',
|
||
'business_type' => '业务类型',
|
||
'remark' => '备注',
|
||
'online_order_no' => '线上单号',
|
||
'online_real_store' => '线上单号对应实际店铺',
|
||
'business_description' => '业务描述',
|
||
'business_bill_source' => '业务账单来源',
|
||
'business_order_no' => '业务订单号',
|
||
'created_at' => '创建时间',
|
||
'updated_at' => '修改时间',
|
||
'modifier' => '修改人',
|
||
];
|
||
|
||
/**
|
||
* 淘系支付宝账户账单列表(店铺收款账户账单 › 淘系支付宝账户)。
|
||
* 经由 admin 路由组(web 会话)暴露,前端带 CSRF 调用即可。
|
||
*/
|
||
public function index(Request $request, StoreReceivingAlipayBillMirrorRepository $repository)
|
||
{
|
||
$this->authorizeStoreReceivingBills();
|
||
|
||
$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';
|
||
$filters = $request->validate([
|
||
'store_name' => ['nullable', 'string', 'max:255'],
|
||
'ledger_remark' => ['nullable', 'string', 'max:191'],
|
||
'source_channel' => ['nullable', 'string', 'max:128'],
|
||
'bill_item' => ['nullable', 'string', 'max:255'],
|
||
'business_type' => ['nullable', 'string', 'max:255'],
|
||
'start' => ['nullable', 'date_format:Y-m-d'],
|
||
'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'],
|
||
'keyword' => ['nullable', 'string', 'max:255'],
|
||
]);
|
||
$filters = $this->withLedgerRemarkFilter(
|
||
array_map(static fn (mixed $value): string => trim((string) $value), $filters),
|
||
'taobao-alipay',
|
||
);
|
||
|
||
try {
|
||
return response()->json($this->withLedgerRemarks(
|
||
$repository->paginate(
|
||
$filters,
|
||
$sort,
|
||
$dir,
|
||
max((int) $request->get('page', 1), 1),
|
||
min(max((int) $request->get('limit', 10), 1), 100),
|
||
),
|
||
'taobao-alipay',
|
||
));
|
||
} 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' => min(max((int) $request->get('limit', 10), 1), 100),
|
||
'summary' => [
|
||
'income' => 0,
|
||
'expense' => 0,
|
||
'opening_balance' => 0,
|
||
'opening_balance_date' => null,
|
||
'balance' => 0,
|
||
'count' => 0,
|
||
],
|
||
], 503);
|
||
}
|
||
}
|
||
|
||
public function filterOptions(StoreReceivingAlipayBillMirrorRepository $repository)
|
||
{
|
||
$this->authorizeStoreReceivingBills();
|
||
|
||
$ledgerRemarks = $this->ledgerRemarkMap()->remarksForTab('taobao-alipay');
|
||
|
||
try {
|
||
return response()->json($repository->filterOptionsForChannel(
|
||
'alipayCSV', StoreReceivingAlipayBillMirrorRepository::DATASET, true
|
||
) + ['ledger_remarks' => $ledgerRemarks]);
|
||
} catch (RuntimeException $exception) {
|
||
if (! in_array($exception->getMessage(), [
|
||
'store_receiving_alipay_bill_mirror_not_ready',
|
||
'store_receiving_bill_filter_options_busy',
|
||
], true)) {
|
||
throw $exception;
|
||
}
|
||
|
||
return response()->json([
|
||
'message' => $exception->getMessage() === 'store_receiving_bill_filter_options_busy'
|
||
? '淘系支付宝筛选选项正在生成,请稍后重试。'
|
||
: '淘系支付宝账单共享业务镜像正在首次同步,筛选选项稍后可用。',
|
||
'shops' => [],
|
||
'bill_items' => [],
|
||
'ledger_remarks' => $ledgerRemarks,
|
||
], 503);
|
||
}
|
||
}
|
||
|
||
public function export(Request $request, StoreReceivingAlipayBillMirrorRepository $repository)
|
||
{
|
||
$this->authorizeStoreReceivingBills();
|
||
$exportToken = $this->exportProgressToken($request);
|
||
$exportTotal = max(0, (int) $request->get('total', 0));
|
||
$this->putExportProgress($exportToken, 0, $exportTotal);
|
||
|
||
$filters = $request->validate([
|
||
'store_name' => ['nullable', 'string', 'max:255'],
|
||
'ledger_remark' => ['nullable', 'string', 'max:191'],
|
||
'source_channel' => ['nullable', 'string', 'max:128'],
|
||
'bill_item' => ['nullable', 'string', 'max:255'],
|
||
'business_type' => ['nullable', 'string', 'max:255'],
|
||
'start' => ['nullable', 'date_format:Y-m-d'],
|
||
'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'],
|
||
'keyword' => ['nullable', 'string', 'max:255'],
|
||
'columns' => ['nullable', 'array', 'max:25'],
|
||
'columns.*' => ['string', 'max:64'],
|
||
]);
|
||
|
||
$columns = $this->requestedExportColumns($filters['columns'] ?? []);
|
||
unset($filters['columns']);
|
||
$filters = $this->withLedgerRemarkFilter(
|
||
array_map(static fn (mixed $value): string => trim((string) $value), $filters),
|
||
'taobao-alipay',
|
||
);
|
||
|
||
try {
|
||
$sortable = ['record_code', 'occurred_at', 'income', 'expense', 'balance'];
|
||
$sort = in_array($request->get('sort'), $sortable, true) ? $request->get('sort') : 'occurred_at';
|
||
$dir = $request->get('dir') === 'asc' ? 'asc' : 'desc';
|
||
$rows = $repository->exportRows($filters, $sort, $dir);
|
||
} catch (RuntimeException $exception) {
|
||
if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') {
|
||
throw $exception;
|
||
}
|
||
|
||
return response()->json(['message' => '淘系支付宝账单共享业务镜像正在首次同步,完成后将自动显示。'], 503);
|
||
}
|
||
|
||
$rows = $this->ledgerRemarkRows($rows, 'taobao-alipay');
|
||
|
||
return response()->streamDownload(function () use ($rows, $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, array_values($columns));
|
||
flush();
|
||
$written = 0;
|
||
foreach ($rows as $row) {
|
||
fputcsv($csv, array_map(
|
||
fn (string $field): string => $this->csvValue($row[$field] ?? '', $field),
|
||
array_keys($columns),
|
||
));
|
||
$written++;
|
||
if ($written % 5000 === 0) {
|
||
$this->putExportProgress($exportToken, $written, $exportTotal);
|
||
}
|
||
}
|
||
fclose($csv);
|
||
$this->putExportProgress($exportToken, $written, $exportTotal, 'done');
|
||
}, 'taobao_alipay_bills.csv', [
|
||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||
// 反向代理(服务器上的 nginx)不能缓冲这个响应,否则浏览器要等整个文件生成完才开始下载。
|
||
'X-Accel-Buffering' => 'no',
|
||
]);
|
||
}
|
||
|
||
/** @return array<string,string> */
|
||
private function requestedExportColumns(array $requested): array
|
||
{
|
||
if ($requested === []) {
|
||
return self::EXPORT_COLUMNS;
|
||
}
|
||
|
||
$columns = [];
|
||
foreach ($requested as $field) {
|
||
if (isset(self::EXPORT_COLUMNS[$field])) {
|
||
$columns[$field] = self::EXPORT_COLUMNS[$field];
|
||
}
|
||
}
|
||
|
||
abort_if($columns === [], 422, '至少选择一个可导出的字段。');
|
||
|
||
return $columns;
|
||
}
|
||
|
||
private function csvValue(mixed $value, string $field): string
|
||
{
|
||
$text = (string) ($value ?? '');
|
||
if (preg_match('/(?:_no|_code|serial|goods_name)$/', $field) && preg_match('/^\d{12,}$/', $text)) {
|
||
return "\t" . $text;
|
||
}
|
||
|
||
return preg_match('/^[=+\-@]/', $text) ? "'" . $text : $text;
|
||
}
|
||
|
||
public function show(AlipayBill $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, '当前账号没有店铺收款账户账单权限。');
|
||
}
|
||
}
|