chenxi fe8c271fcf 商品成本:新增财务经营报表「商品成本」页面与同步命令
以业绩明细的账单行为基数,按「店铺 + 线上订单号」展开订单原始数据的商品行:
- 发货仓库取订单 wms_co_id 对应的聚水潭仓库名称,仓库简称查仓库映射表
- 商品单价按「仓库简称 + 商品编码」取商品月加权平均单价里的加权平均价
- 商品成本 = 商品数量 × 商品单价(四舍五入两位小数)
- 拆单子单重复列出的同一批商品只算一次;同一单分多仓发货按子单实际仓库分行
- 排除虚拟条码(X0000x)与数量金额都为 0 的占位行;默认隐藏仓库映射表里标「-不看」的仓
页面提供月份、平台、店铺、线上单号、商品编码、仓库简称筛选与汇总、CSV 导出。
2026-10-09 17:36:14 +08:00

6593 lines
295 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 App\Http\Controllers\Business;
use App\Business\BusinessTableImporter;
use App\Business\InventoryWarehouseNameMappingWorkbookImporter;
use App\Business\InventoryWarehouseShortNames;
use App\Business\CourierPayableSummaryFilter;
use App\Business\CourierPayableSummaryStore;
use App\Business\FundForecastTemplate;
use App\Business\TableRegistry;
use App\Business\UpstreamBusinessInformation;
use App\Business\StoreReceivingAccountTemplate;
use App\Business\StoreReceivingAccountWorkbookImporter;
use App\Business\StoreViolationWorkbookImporter;
use App\Business\WithdrawalManagementReport;
use App\Jobs\Common\InventoryReportExport;
use App\Models\Banking\MarketingAccountBill;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Models\Business\CourierSigningSubject;
use App\Models\Business\InstallationDetail;
use App\Models\Business\InventoryWarehouseNameMapping;
use App\Business\CourierSurchargeAuditLogRepository;
use App\Business\CourierSurchargeQuoteRepository;
use App\Business\CourierSurchargeTypeRepository;
use App\Models\StoreReceivables\StoreZhongcaiReconciliation;
use App\Utilities\SidebarPermissions;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Illuminate\Database\QueryException;
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository;
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
use Modules\EcommerceFinance\View\BillOffsetPageData;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
use Modules\EcommerceFinance\Repositories\InventoryRepository;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Repositories\InternalFlowReportRepository;
use Modules\EcommerceFinance\Repositories\JdPromotionAccountBalanceRepository;
use Modules\EcommerceFinance\Repositories\JdWalletBillRepository;
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
use Modules\EcommerceFinance\Repositories\RawBoardPayablesRepository;
use Modules\EcommerceFinance\Repositories\DingPayableWorkflowMirrorRepository;
use Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository;
use Modules\EcommerceFinance\Repositories\SmallPaymentWorkOrderMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\AccountMappingValidator;
use Modules\EcommerceFinance\Services\StoreReportInitialPeriods;
use Modules\EcommerceFinance\Services\TaxForecastLogic;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Modules\EcommerceFinance\Services\SupplierPayableMaintenanceRegistry;
use Modules\EcommerceFinance\Services\PayablesFinalRegistry;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Modules\EcommerceFinance\View\PlatformPromotionAccountPageData;
use Modules\EcommerceFinance\View\OrderDataPageData;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Throwable;
class Pages extends Controller
{
private const EDGE_BANDING_SOURCE = 'jiansun';
private const INVENTORY_SOURCE = 'jiansun';
private const RAW_BOARD_SOURCE = 'jiansun';
/**
* 「终版」目录只提供空的表结构,数据由各自按表导入,不解析其他目录的既有页面内容。
*/
private const PAYABLES_FINAL_SECTIONS = ['supplier-payables-final', 'courier-payables-final'];
/** 业绩明细的平台筛选顺序(与店铺收款账户账单页签一致)。 */
private const RECEIPT_PLATFORM_OPTIONS = [
'淘系支付宝',
'淘系微信',
'拼多多',
'京东账单',
'京喜账单',
'京东网银钱包',
'抖音',
'得物',
];
/** 业绩明细「匹配状态」筛选项:只保留基础匹配状态。 */
private const RECEIPT_STATUS_OPTIONS = [
'已匹配',
'未匹配',
];
/** 业绩明细「异常标注」筛选项,取值与同步命令写入的标注一致。 */
private const RECEIPT_REMARK_OPTIONS = [
'账单无单号',
'单号格式异常',
'订单表无此单',
'订单无商品金额',
'无收款账单',
'店铺名不一致',
'订单侧同号多条',
'同单多笔收款',
];
public function __construct(
private readonly EdgeBandingRepository $edgeBanding,
private readonly InventoryRepository $inventory,
private readonly InstallationDetailMirrorRepository $installationDetails,
private readonly NonJingxiRefundMirrorRepository $nonJingxiRefunds,
private readonly SmallPaymentWorkOrderMirrorRepository $smallPaymentWorkOrders,
private readonly ProductCatalogMirrorRepository $productCatalog,
private readonly ProductPriceMirrorRepository $productPrices,
private readonly CourierInformationMirrorRepository $courierInformation,
private readonly CourierFreightReconciliationMirrorRepository $courierFreightReconciliations,
private readonly ThirdPartyWarehouseStorageMirrorRepository $thirdPartyWarehouseStorage,
private readonly RawBoardPayablesRepository $rawBoardPayables,
private readonly DingPayableWorkflowMirrorRepository $dingPayableWorkflows,
private readonly DingOnlineDaifaPriceMirrorRepository $onlineDaifaPrices,
private readonly SupplierPayableReportRepository $supplierPayableReports,
private readonly SupplierPayableOverrideStore $supplierPayableOverrides,
private readonly SupplierPayableMaintenanceRegistry $supplierPayableMaintenance,
private readonly UpstreamQueryCacheFactory $upstreamCaches,
private readonly CourierSurchargeQuoteRepository $courierSurchargeQuotes,
private readonly CourierSurchargeTypeRepository $courierSurchargeTypes,
private readonly CourierSurchargeAuditLogRepository $courierSurchargeAudits,
private readonly WithdrawalManagementReport $withdrawalManagementReport,
private readonly ShippingOrderRepository $shippingOrders,
) {
$this->middleware('auth');
$this->middleware('company.identify');
}
/**
* 支持的营销账户平台列表
*/
protected const MARKETING_PLATFORMS = [
'taobao-alipay', 'taobao-wechat', 'pinduoduo',
'jd-wallet', 'douyin', 'dewu', 'vipshop',
'jd-promotion', 'douyin-promotion', 'dewu-promotion', 'vip-promotion',
];
/** @var list<string> 推广账户流水已合并到营销账户账单页的页签 */
protected const PROMOTION_MARKETING_TABS = [
'jd-promotion', 'douyin-promotion', 'dewu-promotion', 'vip-promotion',
];
protected const ACCOUNT_BALANCE_SECTION = 'account-balance-data';
protected const SMALL_PAYMENT_SECTION = 'financial-operations';
/** 商品成本明细的共享镜像表(由 store-receipt:product-cost:sync 生成)。 */
protected const PRODUCT_COST_TABLE = 'store_receipt_product_cost_rows';
protected const ACCOUNT_BALANCE_PAGES = [
'bank-balance',
'store-receiving-balance',
'marketing-account-balance',
'deposit-account-balance',
];
public function home()
{
$section = collect(SidebarPermissions::navigationFor(user()))->first();
if (empty($section)) {
return view('business.access-pending');
}
$page = collect($section['pages'] ?? [])->first();
$parameters = ['section' => $section['slug']];
if ($page) {
$parameters['page'] = $page['slug'];
if (! empty($page['tabs'])) {
$parameters['tab'] = $page['tabs'][0]['slug'];
}
}
return redirect()->route('business.pages.show', $parameters);
}
public function show(string $section, ?string $page = null, ?string $tab = null)
{
if ($section === 'refunds' && $page === 'small-payment-work-orders') {
$url = route('business.pages.show', [
'section' => self::SMALL_PAYMENT_SECTION,
'page' => $page,
]);
$query = request()->getQueryString();
return redirect()->to($url . ($query === null ? '' : '?' . $query));
}
if ($section === 'billing-transactions' && in_array($page, self::ACCOUNT_BALANCE_PAGES, true)) {
$url = route('business.pages.show', array_filter([
'section' => self::ACCOUNT_BALANCE_SECTION,
'page' => $page,
'tab' => $tab,
], static fn (mixed $value): bool => $value !== null));
$query = request()->getQueryString();
return redirect()->to($url . ($query === null ? '' : '?' . $query));
}
$sectionConfig = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', $section);
abort_if(empty($sectionConfig), 403, '当前账号没有该页面权限。');
$pages = collect($sectionConfig['pages'] ?? []);
if ($pages->isEmpty()) {
abort_if($page !== null || $tab !== null, 404);
return view('business.pages.show', $this->viewData($sectionConfig));
}
if ($page === null) {
if ($section === 'courier-payables' || in_array($section, self::PAYABLES_FINAL_SECTIONS, true)) {
return view('business.pages.show', $this->viewData($sectionConfig));
}
return $this->redirectToPage($sectionConfig, $pages->first());
}
$pageConfig = $pages
->flatMap(fn (array $configuredPage) => [
$configuredPage,
...($configuredPage['children'] ?? []),
])
->firstWhere('slug', $page);
abort_if(empty($pageConfig), 404);
if (! empty($pageConfig['children']) && empty($pageConfig['tabs'])) {
return $this->redirectToPage($sectionConfig, $pageConfig['children'][0]);
}
$tabs = collect($pageConfig['tabs'] ?? []);
if ($tabs->isEmpty()) {
// Old bookmarked tab URLs should land on the now-empty parent
// directory while the receiving-account pages are being rebuilt.
if ($tab !== null) {
return redirect()->route('business.pages.show', [
'section' => $sectionConfig['slug'],
'page' => $pageConfig['slug'],
]);
}
if ($section === 'courier-payables' && $page === 'freight-reconciliation' && request()->boolean('courier_summary')) {
try {
$summary = $this->courierFreightReconciliations->requestSummary($this->freightReconciliationFilters());
$response = response()->json(['data' => $summary], $summary === null ? 202 : 200, [
'Cache-Control' => 'private, no-store', 'X-Accel-Buffering' => 'no',
]);
// Explicit framing lets clients finish reading before the
// application termination callback computes the aggregate.
return $response->header('Content-Length', strlen($response->getContent()));
} catch (Throwable $exception) {
report($exception);
return response()->json(['message' => '完整汇总暂时无法计算,明细仍可正常查看。'], 503, ['Cache-Control' => 'private, no-store']);
}
}
return view('business.pages.show', $this->viewData(
$sectionConfig,
$pageConfig,
$tabs,
null,
$this->resolveContentForSection($section, $page, null),
));
}
if ($tab === null) {
return $this->redirectToPage($sectionConfig, $pageConfig);
}
$activeTab = $tabs->firstWhere('slug', $tab);
abort_if(empty($activeTab), 404);
return view('business.pages.show', $this->viewData(
$sectionConfig,
$pageConfig,
$tabs,
$activeTab,
$this->resolveContentForSection($section, $page, $tab),
));
}
public function installationDetails(string $guid)
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'installation-details'), 403, '当前账号没有该页面权限。');
$payload = $this->installationDetails->details($guid);
if (! $payload['ready']) {
return response()->json(['message' => '安装明细镜像尚未完成首次同步'], 503);
}
return response()->json(['data' => $payload['data'], 'has_more' => $payload['has_more'] ?? false]);
}
/**
* 账单明细导出:按页面同一套筛选条件流式导出 CSV(全部筛选结果)。
*/
public function exportBillingDetails(Request $request): StreamedResponse
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'billing-details'), 403, '当前账号没有该页面权限。');
$pageData = app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class);
$filters = $pageData->exportFilters($request->all());
$columns = $pageData->exportColumns(array_values(array_filter(
(array) $request->get('columns', []),
static fn ($key): bool => is_string($key) && $key !== '',
)));
$cell = static function (array $row, string $key): string {
$income = (float) ($row['income'] ?? 0);
$expense = (float) ($row['expense'] ?? 0);
return match ($key) {
'amount' => number_format($income - $expense, 2, '.', ''),
'income' => $income > 0 ? number_format($income, 2, '.', '') : '',
'expense' => $expense > 0 ? number_format($expense, 2, '.', '') : '',
default => trim((string) ($row[$key] ?? '')),
};
};
$csvText = fn (mixed $value): string => $this->csvText($value);
// 导出进度:前端带 token 发起下载,导出过程中把已导出行数写进缓存,前端轮询显示进度
$token = trim((string) $request->get('token', ''));
$token = preg_match('/^[A-Za-z0-9_-]{8,64}$/', $token) === 1 ? $token : '';
$progressKey = $token === '' ? null : self::billingDetailsProgressKey($token);
$total = $pageData->exportTotal($filters);
$putProgress = static function (array $state) use ($progressKey): void {
if ($progressKey !== null) {
\Illuminate\Support\Facades\Cache::put($progressKey, $state, now()->addMinutes(30));
}
};
$putProgress(['status' => 'running', 'rows' => 0, 'total' => $total]);
return response()->streamDownload(function () use ($pageData, $filters, $columns, $cell, $csvText, $putProgress, $total): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建账单明细导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_map(static fn (array $definition): string => $definition[0], array_values($columns)));
$written = 0;
$putProgress(['status' => 'running', 'rows' => 0, 'total' => $total]);
foreach ($pageData->exportRows($filters, $columns) as $row) {
$line = [];
foreach (array_keys($columns) as $key) {
$value = $cell($row, (string) $key);
// 数值列不做公式转义,否则负数会被写成 '-0.47
$line[] = ($columns[$key][1] ?? '') === 'num' ? $value : $csvText($value);
}
fputcsv($csv, $line);
$written++;
if ($written % 5000 === 0) {
$putProgress(['status' => 'running', 'rows' => $written, 'total' => $total]);
}
}
fclose($csv);
$putProgress(['status' => 'done', 'rows' => $written, 'total' => max($total, $written)]);
}, '账单明细_' . $filters['start'] . '_' . $filters['end'] . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** 账单明细筛选联动:按当前其它条件返回各筛选项可选值与行数 */
public function billingDetailsFilterOptions(): \Illuminate\Http\JsonResponse
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'billing-details'), 403, '当前账号没有该页面权限。');
$data = app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class);
return response()->json(['facets' => $data->facets(request()->all())], 200, [
'Cache-Control' => 'private, no-store',
]);
}
/** 账单明细导出进度(前端每秒轮询一次) */
public function billingDetailsExportProgress(Request $request): \Illuminate\Http\JsonResponse
{
$token = trim((string) $request->get('token', ''));
abort_unless(preg_match('/^[A-Za-z0-9_-]{8,64}$/', $token) === 1, 400, '导出标识无效。');
$state = \Illuminate\Support\Facades\Cache::get(self::billingDetailsProgressKey($token));
return response()->json($state ?? ['status' => 'unknown', 'rows' => 0, 'total' => 0], 200, [
'Cache-Control' => 'private, no-store',
]);
}
private static function billingDetailsProgressKey(string $token): string
{
return 'billing-details-export:' . (int) user()->id . ':' . $token;
}
public function exportInstallationDetails(Request $request): StreamedResponse
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'installation-details'), 403, '当前账号没有该页面权限。');
$filters = InstallationDetailMirrorRepository::requestFilters();
$firstPage = $this->installationDetails->paginate(1, 100, $filters);
abort_if(! $firstPage['ready'], 503, '安装明细镜像尚未完成首次同步');
$labels = InstallationDetail::columnLabels();
return response()->streamDownload(function () use ($filters, $firstPage, $labels): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建安装明细导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_values($labels));
$page = 1;
$result = $firstPage;
do {
foreach ($result['data'] as $row) {
fputcsv($csv, array_map(
fn (string $name): string => $this->csvText($row[$name] ?? ''),
array_keys($labels),
));
}
$page++;
$hasMore = ($page - 1) * 100 < $result['total'];
if ($hasMore) {
$result = $this->installationDetails->paginate($page, 100, $filters);
}
} while ($hasMore);
fclose($csv);
}, '安装明细_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
public function exportBillOffset(Request $request): StreamedResponse
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'order-data');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'bill-offset'), 403, '当前账号没有该页面权限。');
$repository = app(BillOffsetPageData::class);
$filters = $request->query();
$result = $repository->data($filters, 1, 100);
abort_if($result['billOffsetError'] !== null, 503, $result['billOffsetError']);
$filters = $result['billOffsetFilters'];
return response()->streamDownload(function () use ($repository, $filters, $result): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建账单冲减导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
$labels = [
'internal_order_no' => '内部订单号', 'online_order_no' => '线上订单号',
'shop_name' => '店铺名称', 'shop_code' => '店铺编码', 'product_code' => '商品编码',
'style_code' => '款式编码(参考)', 'shop_product_code' => '店铺商品编码',
'shop_style_code' => '店铺款式编码', 'account_500112' => '500112冲减收入',
];
fputcsv($csv, array_values($labels));
$current = $result;
$page = 1;
do {
foreach ($current['billOffsetRows'] as $row) {
fputcsv($csv, array_map(
function (string $field) use ($row): string {
$value = data_get($row, $field);
if ($field === 'account_500112') {
return $value === null || $value === '' ? '' : number_format((float) $value, 2, '.', '');
}
return $field === 'shop_name'
? $this->csvText($value)
: $this->csvIdentifier($value);
},
array_keys($labels),
));
}
if (! $current['billOffsetRows']->hasMorePages()) break;
$current = $repository->dataForNormalizedFilters($filters, ++$page, 100);
if ($current['billOffsetError'] !== null) {
throw new \RuntimeException('账单冲减导出未完成,请重试。');
}
} while (true);
fclose($csv);
}, '账单冲减_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/**
* 商品月加权平均单价:读取已计算的月份数据,默认最新月份。
*/
protected function monthlyWeightedAveragePriceRows(): LengthAwarePaginator
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$months = DB::connection($connection)->table('product_monthly_weighted_average_states')
->where('status', 'computed')
->orderByDesc('report_month')
->pluck('report_month')
->map(static fn ($month): string => substr((string) $month, 0, 10))
->all();
$requested = substr(trim((string) request('month', '')), 0, 10);
if (preg_match('/^\d{4}-\d{2}$/', $requested) === 1) {
// 筛选栏的月份选择器提交 Y-m,这里补成当月 1 号再匹配。
$requested .= '-01';
}
// 明确指定月份时严格按该月份取数(该月没算出来就显示空表),避免筛选条件与数据不一致。
$month = $requested !== '' ? $requested : ($months[0] ?? null);
$limit = min(max((int) setting('default.list_limit', 25), 1), 50);
$page = max((int) request('page', 1), 1);
$empty = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]);
if ($month === null) {
return $empty;
}
$query = DB::connection($connection)->table('product_monthly_weighted_average_rows')
->where('report_month', $month);
$productCode = trim((string) request('product_code', ''));
if ($productCode !== '') {
// 与进销存总表一致:商品编码按精确查询。
$query->where('product_code', $productCode);
}
$provider = trim((string) request('warehouse_provider', ''));
if ($provider !== '') {
$query->where('warehouse_short_name', 'like', '%' . addcslashes($provider, '%_\\') . '%');
}
$category = trim((string) request('category', ''));
if ($category !== '') {
$query->where('product_name', 'like', '%' . addcslashes($category, '%_\\') . '%');
}
$total = (clone $query)->count();
if ($total === 0) {
return $empty;
}
$items = $query
->orderBy('warehouse_short_name')
->orderBy('product_code')
->forPage($page, $limit)
->get([
DB::raw('report_month as month'),
'barcode_warehouse',
'warehouse_short_name',
'supplier',
'product_code',
'product_name',
'opening_quantity',
'opening_unit_price',
'opening_original_amount',
DB::raw('adjusted_unit_price as opening_adjusted_price'),
'opening_amount',
'purchase_quantity',
'purchase_unit_price',
'purchase_amount',
'weighted_average_unit_price',
'pricing_status',
]);
return new LengthAwarePaginator($items, $total, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]);
}
/**
* 业绩明细 → 店铺收款匹配订单明细:以店铺收款账户账单为基数,
* 按「店铺 + 订单号」匹配订单侧商品金额;一行 = 一个店铺的一个订单。
*
* @return array{0:LengthAwarePaginator,1:?array<string,mixed>}
*/
protected function storeReceiptOrderMatchRows(): array
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
[$months, $month] = $this->receiptOrderMatchMonths();
// 每页条数:业绩明细页面可切 10 / 20 / 50,未指定时沿用系统默认(上限 50)。
$requestedLimit = (int) request('per_page', 0);
$limit = in_array($requestedLimit, [10, 20, 50], true)
? $requestedLimit
: min(max((int) setting('default.list_limit', 25), 1), 50);
$page = max((int) request('page', 1), 1);
$empty = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]);
if ($month === null) {
return [$empty, null];
}
// 平台 / 店铺与其它页面一致:可打字搜索的多选(一个都不勾=全部),同时兼容旧的单值链接。
$platformFilter = $this->receiptOrderMatchFilterValues(request('platform'));
$storeFilter = $this->receiptOrderMatchFilterValues(request('store_name'));
[$statusFilter, $remarkFilter] = $this->receiptOrderMatchStatusFilters(request('match_status'), request('remark'));
$orderNo = trim((string) request('online_order_no', ''));
$applyFilters = static function ($query) use ($platformFilter, $storeFilter, $orderNo, $statusFilter, $remarkFilter): void {
if ($platformFilter !== []) {
$query->whereIn('platform', $platformFilter);
}
if ($storeFilter !== []) {
$query->whereIn('store_name', $storeFilter);
}
if ($orderNo !== '') {
$query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%');
}
// 「匹配状态」和「异常标注」是两个独立筛选,各自多选内部是「或」,两者之间是「且」。
if ($statusFilter !== []) {
$query->whereIn('match_status', $statusFilter);
}
if ($remarkFilter !== []) {
$query->whereIn('remark', $remarkFilter);
}
};
// 镜像表本身就是「一个店铺 + 一个订单号 + 一个商品金额」的行口径,直接统计即可。
$base = static fn () => DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('store_receipt_order_match_rows')
->where('report_month', $month);
$countQuery = $base();
$applyFilters($countQuery);
$total = (int) $countQuery->count();
$aggQuery = $base();
$applyFilters($aggQuery);
$agg = $aggQuery->selectRaw("
SUM(payment_count) as payments,
SUM(income_amount) as income_total,
SUM(discount_amount) as discount_total,
SUM(offset_amount) as offset_total,
SUM(planting_amount) as planting_total,
SUM(product_amount) as goods_total,
SUM(difference) as difference_total,
SUM(CASE WHEN payment_count > 1 THEN 1 ELSE 0 END) as multi_pay,
SUM(CASE WHEN income_amount = 0 THEN 1 ELSE 0 END) as zero_income,
SUM(CASE WHEN match_status = '已匹配' THEN 1 ELSE 0 END) as matched,
SUM(CASE WHEN match_status = '未匹配' THEN 1 ELSE 0 END) as unmatched
")->first();
$stats = [
'month' => $month,
// 月份选择器下界:以镜像里实际有数据的月份为准,避免把已同步的账期禁掉。
'min_month' => $months === [] ? null : substr(min($months), 0, 7),
'rows' => $total,
'payments' => (int) ($agg->payments ?? 0),
'orders_matched' => (int) ($agg->matched ?? 0),
'orders_unmatched' => (int) ($agg->unmatched ?? 0),
'orders_multi_pay' => (int) ($agg->multi_pay ?? 0),
'orders_zero_income' => (int) ($agg->zero_income ?? 0),
'income_total' => round((float) ($agg->income_total ?? 0), 2),
'discount_total' => round((float) ($agg->discount_total ?? 0), 2),
'offset_total' => round((float) ($agg->offset_total ?? 0), 2),
'planting_total' => round((float) ($agg->planting_total ?? 0), 2),
'goods_total' => round((float) ($agg->goods_total ?? 0), 2),
'difference_total' => round((float) ($agg->difference_total ?? 0), 2),
'per_page' => $limit,
];
// 筛选下拉候选:本月镜像里实际出现的平台与店铺(按 6 小时缓存,避免每次开页做去重扫描),
// 并把当前已选值并入,保证筛选项的文字不会因为换月而消失。
$filterOptions = cache()->remember(
'performance-details-filter-options-v1:' . $month,
now()->addHours(6),
static function () use ($connection, $month): array {
$maker = static fn () => DB::connection($connection)->table('store_receipt_order_match_rows')
->where('report_month', $month);
return [
'platforms' => $maker()->whereNotNull('platform')->distinct()->orderBy('platform')->pluck('platform')->all(),
'stores' => $maker()->whereNotNull('store_name')->where('store_name', '!=', '')
->distinct()->orderBy('store_name')->pluck('store_name')->all(),
];
},
);
$stats['selected_platforms'] = $platformFilter;
$stats['selected_stores'] = $storeFilter;
$stats['platform_options'] = array_values(array_unique(array_merge(
self::RECEIPT_PLATFORM_OPTIONS,
array_map('strval', $filterOptions['platforms'] ?? []),
)));
$stats['store_options'] = array_values(array_unique(array_merge(
$storeFilter,
array_map('strval', $filterOptions['stores'] ?? []),
)));
$stats['status_options'] = self::RECEIPT_STATUS_OPTIONS;
$stats['remark_options'] = self::RECEIPT_REMARK_OPTIONS;
if ($total === 0) {
return [$empty, $stats];
}
$itemsQuery = $base();
$applyFilters($itemsQuery);
$items = $itemsQuery
->orderBy('occurred_date')
->orderBy('store_name')
->orderBy('online_order_no')
->forPage($page, $limit)
->get();
return [new LengthAwarePaginator($items, $total, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]), $stats];
}
/**
* 业绩明细的多选筛选值:多选控件提交 name[](数组),同时兼容历史单值链接。
*
* @return list<string>
*/
private function receiptOrderMatchFilterValues(mixed $input): array
{
$values = is_array($input) ? $input : [$input];
$clean = [];
foreach ($values as $value) {
if (is_array($value)) {
continue;
}
$text = trim((string) $value);
if ($text !== '') {
$clean[$text] = true;
}
}
return array_keys($clean);
}
/**
* 业绩明细的账期集合与当前账期:未指定月份时取镜像里最新的月份。
*
* @return array{0:list<string>,1:?string} 账期列表(Y-m-d)与当前账期(Y-m-d)
*/
private function receiptOrderMatchMonths(): array
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$months = DB::connection($connection)->table('store_receipt_order_match_rows')
->select('report_month')
->distinct()
->orderByDesc('report_month')
->pluck('report_month')
->map(static fn ($month): string => substr((string) $month, 0, 10))
->all();
$requested = substr(trim((string) request('month', '')), 0, 10);
if (preg_match('/^\d{4}-\d{2}$/', $requested) === 1) {
// 筛选栏的月份选择器提交 Y-m,这里补成当月 1 号再匹配。
$requested .= '-01';
}
// 明确指定月份时严格按该月份取数(该月没同步就显示空表)。
$month = $requested !== '' ? $requested : ($months[0] ?? null);
return [$months, $month];
}
/**
* 归一化「匹配状态 / 异常标注」两组筛选值:状态列与标注列各自独立,
* 同时兼容历史链接把异常标注塞进 match_status 的写法。
*
* @return array{0:list<string>,1:list<string>} 状态值、异常标注值
*/
private function receiptOrderMatchStatusFilters(mixed $statusInput, mixed $remarkInput): array
{
$values = $this->receiptOrderMatchFilterValues($statusInput);
$statusValues = array_values(array_intersect($values, self::RECEIPT_STATUS_OPTIONS));
return [
$statusValues,
array_values(array_unique(array_merge(
array_diff($values, $statusValues),
$this->receiptOrderMatchFilterValues($remarkInput),
))),
];
}
/**
* 业绩明细筛选联动:按「当前其它筛选条件」统计每个筛选项的可选值与行数,
* 行数为 0 的取值不返回(前端不展示)。
*/
public function receiptOrderMatchFilterOptions()
{
[, $month] = $this->receiptOrderMatchMonths();
if ($month === null) {
return response()->json(['month' => null, 'facets' => []]);
}
[$statusValues, $remarkValues] = $this->receiptOrderMatchStatusFilters(request('match_status'), request('remark'));
$filters = [
'platform' => $this->receiptOrderMatchFilterValues(request('platform')),
'store_name' => $this->receiptOrderMatchFilterValues(request('store_name')),
'match_status' => $statusValues,
'remark' => $remarkValues,
];
$orderNo = trim((string) request('online_order_no', ''));
return response()->json([
'month' => substr($month, 0, 7),
'facets' => [
'platform' => $this->receiptOrderMatchFacet($month, 'platform', $filters, $orderNo, self::RECEIPT_PLATFORM_OPTIONS),
'store_name' => $this->receiptOrderMatchFacet($month, 'store_name', $filters, $orderNo),
'match_status' => $this->receiptOrderMatchFacet($month, 'match_status', $filters, $orderNo, self::RECEIPT_STATUS_OPTIONS),
'remark' => $this->receiptOrderMatchFacet($month, 'remark', $filters, $orderNo, self::RECEIPT_REMARK_OPTIONS),
],
]);
}
/**
* 单个筛选项的可选值与行数:应用除该筛选项以外的所有条件。
*
* @param array<string,list<string>> $filters 已归一化的筛选条件(键=列名)
* @param list<string> $order 固定展示顺序,未列出的取值排在后面
* @return array{values:list<string>,counts:array<string,int>}
*/
private function receiptOrderMatchFacet(string $month, string $column, array $filters, string $orderNo, array $order = []): array
{
$query = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('store_receipt_order_match_rows')
->where('report_month', $month);
foreach ($filters as $key => $values) {
if ($key === $column || $values === []) {
continue;
}
$query->whereIn($key, $values);
}
if ($orderNo !== '') {
$query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%');
}
$counts = [];
$rows = $query->select($column)
->selectRaw('count(*) as total')
->whereNotNull($column)
->where($column, '!=', '')
->groupBy($column)
->pluck('total', $column);
foreach ($rows as $value => $total) {
$counts[(string) $value] = (int) $total;
}
$ordered = array_values(array_intersect($order, array_keys($counts)));
$rest = array_values(array_diff(array_keys($counts), $ordered));
sort($rest, SORT_NATURAL | SORT_FLAG_CASE);
return ['values' => array_merge($ordered, $rest), 'counts' => $counts];
}
/**
* 业绩明细 → 店铺收款匹配订单明细导出:按当前月份与筛选条件导出 CSV,列序跟随页面显示列。
*/
/**
* 财务经营报表 › 快递运费:按来源 / 成品配件 / 交易单号查询本地导入的明细。
*/
private function courierFreightRows(): LengthAwarePaginator
{
$query = \App\Models\Business\CourierFreightRow::query()
->where('company_id', company_id());
foreach (['source' => 'source', 'part' => 'part'] as $param => $column) {
$value = trim((string) request($param, ''));
if ($value !== '') {
$query->where($column, $value);
}
}
$transactionNo = trim((string) request('transaction_no', ''));
if ($transactionNo !== '') {
$query->where('transaction_no', 'like', '%' . addcslashes($transactionNo, '%_\\') . '%');
}
$requestedLimit = (int) request('per_page', 0);
$limit = in_array($requestedLimit, [10, 20, 50], true)
? $requestedLimit
: min(max((int) setting('default.list_limit', 25), 1), 50);
$rows = $query->orderByDesc('id')->paginate($limit)->withQueryString();
// 「运费差」「其他附加费汇总」「退件费」不在本表里,按交易单号去「快递应付」对应汇总快照里取。
$summaries = app(\App\Business\CourierPayableSummaryMap::class);
$differences = $summaries->map((int) company_id(), \App\Business\CourierPayableSummaryMap::PAGE_FREIGHT_DIFFERENCE);
$surcharges = $summaries->map((int) company_id(), \App\Business\CourierPayableSummaryMap::PAGE_OTHER_SURCHARGE);
$returnFees = $summaries->map((int) company_id(), \App\Business\CourierPayableSummaryMap::PAGE_RETURN_FEE);
$rows->setCollection($rows->getCollection()->map(static function ($row) use ($differences, $surcharges, $returnFees) {
$orderNo = trim((string) $row->transaction_no);
$pick = static fn (array $map): ?string => $orderNo !== '' && isset($map[$orderNo]) ? $map[$orderNo] : null;
$row->setAttribute('freight_difference', $pick($differences));
// 结算运费 = 提供成本运费 + 运费差(运费差本身就是「结算运费 − 提供成本运费」)。
$difference = $pick($differences);
$freightAmount = (float) ($row->freight_amount ?? 0);
$settled = null;
if ($difference !== null && is_numeric($difference)) {
$settled = $freightAmount + (float) $difference;
} elseif ($freightAmount !== 0.0) {
$settled = $freightAmount;
}
$row->setAttribute('settled_freight', $settled);
$row->setAttribute('other_surcharge_amount', $pick($surcharges));
$row->setAttribute('return_fee_amount', $pick($returnFees));
return $row;
}));
return $rows;
}
public function exportPerformanceDetails(Request $request): StreamedResponse
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
// [列名, 表头, 数值格式] —— 金额统一两位小数,数量取整,与页面展示一致。
$map = [
'occurred_date' => ['occurred_date', '发生时间', 'text'],
'platform' => ['platform', '平台', 'text'],
'store_name' => ['store_name', '店铺名称', 'text'],
'online_order_no' => ['online_order_no', '线上单号', 'text'],
'income_amount' => ['income_amount', '收入金额', 'money'],
'discount_amount' => ['discount_amount', '优惠金额', 'money'],
'offset_amount' => ['offset_amount', '冲减金额', 'money'],
'planting_amount' => ['planting_amount', '种菜金额', 'money'],
'product_amount' => ['product_amount', '商品金额', 'money'],
'paid_amount' => ['paid_amount', '订单实付金额', 'money'],
'difference' => ['difference', '差额', 'money'],
'match_status' => ['match_status', '匹配状态', 'text'],
'remark' => ['remark', '异常标注', 'text'],
];
[, $month] = $this->receiptOrderMatchMonths();
$query = DB::connection($connection)->table('store_receipt_order_match_rows')
->when($month !== null, static fn ($builder) => $builder->where('report_month', $month));
$platforms = $this->receiptOrderMatchFilterValues($request->query('platform'));
if ($platforms !== []) {
$query->whereIn('platform', $platforms);
}
$stores = $this->receiptOrderMatchFilterValues($request->query('store_name'));
if ($stores !== []) {
$query->whereIn('store_name', $stores);
}
$orderNo = trim((string) $request->query('online_order_no', ''));
if ($orderNo !== '') {
$query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%');
}
$statusFilter = $this->receiptOrderMatchFilterValues($request->query('match_status'));
[$statusValues, $remarkValues] = $this->receiptOrderMatchStatusFilters($statusFilter, $request->query('remark'));
if ($statusValues !== []) {
$query->whereIn('match_status', $statusValues);
}
if ($remarkValues !== []) {
$query->whereIn('remark', $remarkValues);
}
$selected = array_values(array_intersect(
array_filter(explode(',', (string) $request->query('columns', ''))),
array_keys($map),
));
if ($selected === []) {
$selected = array_keys($map);
}
// 导出「当前页」时与页面保持一致:按页面当前的每页条数(10/20/50)取数。
$requestedSize = (int) $request->query('per_page', 0);
$size = in_array($requestedSize, [10, 20, 50], true)
? $requestedSize
: min(max((int) setting('default.list_limit', 25), 1), 50);
if ($request->query('scope') === 'page') {
$query->forPage(max((int) $request->query('page', 1), 1), $size);
}
$filename = '店铺收款匹配订单明细_' . ($month ?? 'all') . '.csv';
return response()->streamDownload(function () use ($query, $selected, $map): void {
// 单表几十万行:用游标一次查询顺序吐出行,避免 chunk 的 OFFSET 反复重跑
// (29 万行时会退化成几百次排序查询,导出要几分钟)。
@set_time_limit(0);
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, array_map(static fn (string $key): string => $map[$key][1], $selected));
$rows = $query->orderBy('occurred_date')->orderBy('store_name')->orderBy('online_order_no')->cursor();
foreach ($rows as $row) {
$line = [];
foreach ($selected as $key) {
$format = $map[$key][2];
$value = $row->{$map[$key][0]} ?? '';
if ($format === 'money') {
// 金额列空值导出成 0.00:Excel 里直接求和、做差额核对不用再补空。
$line[] = number_format((float) ($value === null || $value === '' ? 0 : $value), 2, '.', '');
continue;
}
if ($format === 'int') {
$line[] = $value === null || $value === '' ? '' : (string) (int) $value;
continue;
}
$line[] = is_string($value) ? $value : (string) $value;
}
fputcsv($out, $line);
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/**
* 商品成本:以「业绩明细」的账单行为基数,按「店铺 + 线上订单号」展开订单侧商品行,
* 带出发货仓库、仓库简称(仓库映射表)、商品单价(商品月加权平均单价)与商品成本
* (商品数量 × 商品单价)。
*/
protected function contentForProductCosts(): array
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$table = self::PRODUCT_COST_TABLE;
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$page = max((int) request('page', 1), 1);
$base = static fn () => DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table(self::PRODUCT_COST_TABLE);
try {
$months = cache()->remember(
'product-costs-months-v1',
now()->addMinutes(10),
static fn () => DB::connection($connection)->table($table)
->distinct()->orderByDesc('report_month')->pluck('report_month')
->map(static fn ($value): string => substr((string) $value, 0, 7))->all(),
);
} catch (Throwable) {
$months = [];
}
$monthInput = trim((string) request('month', ''));
$month = preg_match('/^\d{4}-\d{2}$/', $monthInput) === 1
? $monthInput . '-01'
: ($months !== [] ? $months[0] . '-01' : null);
$filters = [
'month' => $month === null ? '' : substr($month, 0, 7),
'platform' => trim((string) request('platform', '')),
'store_name' => trim((string) request('store_name', '')),
'online_order_no' => trim((string) request('online_order_no', '')),
'product_code' => trim((string) request('product_code', '')),
'warehouse_short_name' => trim((string) request('warehouse_short_name', '')),
'missing_price' => request()->boolean('missing_price') ? '1' : '',
'include_excluded' => request()->boolean('include_excluded') ? '1' : '',
];
$empty = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]);
if ($month === null) {
return [
'view' => 'business.pages.financial-operations.product-costs',
'productCostRows' => $empty,
'productCostFilters' => $filters,
'productCostSummary' => [],
'productCostMonths' => [],
'productCostPlatforms' => [],
'productCostWarehouses' => [],
'productCostError' => null,
];
}
$applyFilters = static function ($query, bool $excludedWarehouseOnly = false) use ($filters): void {
$query->where('report_month', $filters['month'] . '-01');
if ($filters['platform'] !== '') {
$query->where('platform', $filters['platform']);
}
foreach (['store_name' => 'store_name', 'online_order_no' => 'online_order_no', 'product_code' => 'product_code'] as $key => $column) {
if ($filters[$key] !== '') {
$query->where($column, 'like', '%' . addcslashes($filters[$key], '%_\\') . '%');
}
}
if ($filters['warehouse_short_name'] !== '') {
$query->where('warehouse_short_name', $filters['warehouse_short_name']);
}
if ($filters['missing_price'] === '1') {
$query->whereNull('unit_price');
}
// 仓库映射表里标「不看」的仓(次品 / 销退 / 虚拟 / 信誉 / 京东云仓等)默认不展示:
// 这些仓在商品月加权平均单价里本来就没有价,展示出来只会让「未匹配单价」虚高。
if ($excludedWarehouseOnly) {
$query->where('warehouse_short_name', 'like', '%-不看%');
return;
}
if ($filters['include_excluded'] !== '1') {
$query->where(static function ($inner): void {
$inner->whereNull('warehouse_short_name')
->orWhere('warehouse_short_name', 'not like', '%-不看%');
});
}
};
$countQuery = $base();
$applyFilters($countQuery);
$total = (int) $countQuery->count();
$excludedQuery = $base();
$applyFilters($excludedQuery, true);
$excludedRows = (int) $excludedQuery->count();
$aggQuery = $base();
$applyFilters($aggQuery);
$agg = $aggQuery->selectRaw('
SUM(CASE WHEN unit_price IS NOT NULL THEN product_quantity ELSE 0 END) as quantity_total,
SUM(CASE WHEN unit_price IS NOT NULL THEN product_cost ELSE 0 END) as cost_total,
SUM(CASE WHEN unit_price IS NULL THEN 1 ELSE 0 END) as missing_price_rows,
SUM(CASE WHEN unit_price IS NULL THEN product_quantity ELSE 0 END) as missing_price_quantity,
SUM(CASE WHEN warehouse_short_name IS NULL THEN 1 ELSE 0 END) as missing_warehouse_rows
')->first();
$options = cache()->remember(
'product-costs-options-v1:' . $filters['month'],
now()->addMinutes(10),
static function () use ($connection, $table, $filters): array {
$maker = static fn () => DB::connection($connection)->table($table)
->where('report_month', $filters['month'] . '-01');
return [
'platforms' => $maker()->whereNotNull('platform')->distinct()->orderBy('platform')->pluck('platform')->all(),
'warehouses' => $maker()->whereNotNull('warehouse_short_name')->where('warehouse_short_name', '!=', '')
->distinct()->orderBy('warehouse_short_name')->pluck('warehouse_short_name')->all(),
];
},
);
$itemsQuery = $base();
$applyFilters($itemsQuery);
$items = $total === 0 ? collect() : $itemsQuery
->orderBy('occurred_date')
->orderBy('store_name')
->orderBy('online_order_no')
->orderBy('warehouse_short_name')
->orderBy('product_code')
->forPage($page, $limit)
->get();
return [
'view' => 'business.pages.financial-operations.product-costs',
'productCostRows' => new LengthAwarePaginator($items, $total, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]),
'productCostFilters' => $filters,
'productCostSummary' => [
'rows' => $total,
'quantity_total' => round((float) ($agg->quantity_total ?? 0), 2),
'cost_total' => round((float) ($agg->cost_total ?? 0), 2),
'missing_price_rows' => (int) ($agg->missing_price_rows ?? 0),
'missing_price_quantity' => round((float) ($agg->missing_price_quantity ?? 0), 2),
'missing_warehouse_rows' => (int) ($agg->missing_warehouse_rows ?? 0),
'excluded_rows' => $excludedRows,
],
'productCostMonths' => $months,
'productCostPlatforms' => array_values(array_map('strval', $options['platforms'] ?? [])),
'productCostWarehouses' => array_values(array_map('strval', $options['warehouses'] ?? [])),
'productCostError' => null,
];
}
/**
* 商品成本导出:按当前筛选条件流式导出 CSV,列序与页面一致。
*/
public function exportProductCosts(Request $request): StreamedResponse
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$columns = [
['platform', '平台'],
['occurred_date', '发生时间'],
['store_name', '店铺名称'],
['online_order_no', '线上单号'],
['warehouse', '发货仓库'],
['warehouse_short_name', '仓库简称'],
['product_code', '商品编码'],
['product_quantity', '商品数量'],
['unit_price', '商品单价'],
['product_cost', '商品成本'],
['remark', '备注'],
];
$monthInput = trim((string) $request->query('month', ''));
$month = preg_match('/^\d{4}-\d{2}$/', $monthInput) === 1 ? $monthInput . '-01' : null;
$query = DB::connection($connection)->table(self::PRODUCT_COST_TABLE)
->when($month !== null, static fn ($builder) => $builder->where('report_month', $month));
if ($month === null) {
$query->whereRaw('1 = 0');
}
$platform = trim((string) $request->query('platform', ''));
if ($platform !== '') {
$query->where('platform', $platform);
}
foreach (['store_name', 'online_order_no', 'product_code'] as $column) {
$value = trim((string) $request->query($column, ''));
if ($value !== '') {
$query->where($column, 'like', '%' . addcslashes($value, '%_\\') . '%');
}
}
$warehouse = trim((string) $request->query('warehouse_short_name', ''));
if ($warehouse !== '') {
$query->where('warehouse_short_name', $warehouse);
}
if ($request->boolean('missing_price')) {
$query->whereNull('unit_price');
}
if (! $request->boolean('include_excluded')) {
$query->where(static function ($inner): void {
$inner->whereNull('warehouse_short_name')
->orWhere('warehouse_short_name', 'not like', '%-不看%');
});
}
$filename = '商品成本_' . ($month === null ? 'all' : substr($month, 0, 7)) . '.csv';
return response()->streamDownload(function () use ($query, $columns): void {
@set_time_limit(0);
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, array_map(static fn (array $column): string => $column[1], $columns));
$rows = $query->orderBy('occurred_date')->orderBy('store_name')
->orderBy('online_order_no')->orderBy('warehouse_short_name')->orderBy('product_code')
->cursor();
foreach ($rows as $row) {
$line = [];
foreach ($columns as [$key]) {
$value = $row->{$key} ?? '';
if (in_array($key, ['product_quantity', 'unit_price', 'product_cost'], true)) {
$line[] = $value === null || $value === '' ? '' : number_format((float) $value, 2, '.', '');
continue;
}
$line[] = is_string($value) ? $value : (string) $value;
}
fputcsv($out, $line);
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/**
* 商品月加权平均单价导出:按当前月份与筛选条件导出 CSV,列序跟随页面显示列。
*/
public function exportMonthlyWeightedAveragePrice(Request $request): StreamedResponse
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$map = [
'month' => ['report_month', '月份'],
'barcode_warehouse' => ['barcode_warehouse', '条码+仓库'],
'warehouse_short_name' => ['warehouse_short_name', '仓库简称'],
'supplier' => ['supplier', '供应商'],
'product_code' => ['product_code', '商品编码'],
'product_name' => ['product_name', '商品名称'],
'opening_quantity' => ['opening_quantity', '原始数量'],
'opening_unit_price' => ['opening_unit_price', '原始价格'],
'opening_original_amount' => ['opening_original_amount', '原始期初金额'],
'opening_adjusted_price' => ['adjusted_unit_price', '更改价格'],
'opening_amount' => ['opening_amount', '期初金额'],
'purchase_quantity' => ['purchase_quantity', '采购数量'],
'purchase_unit_price' => ['purchase_unit_price', '采购单价'],
'purchase_amount' => ['purchase_amount', '入库金额'],
'weighted_average_unit_price' => ['weighted_average_unit_price', '加权平均单价'],
'pricing_status' => ['pricing_status', '定价状态'],
];
$months = DB::connection($connection)->table('product_monthly_weighted_average_states')
->where('status', 'computed')->orderByDesc('report_month')->pluck('report_month')
->map(static fn ($month): string => substr((string) $month, 0, 10))->all();
$requested = substr(trim((string) $request->query('month', '')), 0, 10);
if (preg_match('/^\d{4}-\d{2}$/', $requested) === 1) {
// 导出时筛选栏提交的也是 Y-m,同样补成当月 1 号。
$requested .= '-01';
}
$month = $requested !== '' ? $requested : ($months[0] ?? null);
$query = DB::connection($connection)->table('product_monthly_weighted_average_rows');
if ($month !== null) {
$query->where('report_month', $month);
}
$code = trim((string) $request->query('product_code', ''));
if ($code !== '') {
$query->where('product_code', $code);
}
$provider = trim((string) $request->query('warehouse_provider', ''));
if ($provider !== '') {
$query->where('warehouse_short_name', 'like', '%' . addcslashes($provider, '%_\\') . '%');
}
$category = trim((string) $request->query('category', ''));
if ($category !== '') {
$query->where('product_name', 'like', '%' . addcslashes($category, '%_\\') . '%');
}
$selected = array_values(array_intersect(
array_filter(explode(',', (string) $request->query('columns', ''))),
array_keys($map),
));
if ($selected === []) {
$selected = array_keys($map);
}
$size = min(max((int) setting('default.list_limit', 25), 1), 50);
if ($request->query('scope') === 'page') {
$query->forPage(max((int) $request->query('page', 1), 1), $size);
}
$filename = '商品月加权平均单价_' . ($month ?? 'all') . '.csv';
return response()->streamDownload(function () use ($query, $selected, $map): void {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, array_map(static fn (string $key): string => $map[$key][1], $selected));
$query->orderBy('warehouse_short_name')->orderBy('product_code')
->chunk(1000, function ($rows) use ($out, $selected, $map): void {
foreach ($rows as $row) {
$line = [];
foreach ($selected as $key) {
$value = $row->{$map[$key][0]} ?? '';
$line[] = is_string($value) ? $value : (string) $value;
}
fputcsv($out, $line);
}
});
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/**
* 进销存报表 → 入库明细数据导出(按月份/仓库/条码筛选,列顺序与页面一致)。
*/
public function exportInventoryInboundDetail(Request $request): StreamedResponse
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$labels = [
'source_no' => '来源单号', 'document_no' => '单据编号(采购单号)', 'created_serial' => '创建时间',
'supplier' => '供应商', 'item_no' => '货号', 'barcode' => '条码', 'spec_name' => '规格名',
'quantity' => '库存变动量', 'unit_price' => '单价', 'amount' => '金额',
'estimated_unit_price' => '预估单价', 'estimated_amount' => '预估金额',
'total' => '总计',
'supplier_barcode' => '供应商条码', 'remark' => '备注', 'warehouse' => '仓库',
'warehouse_short_name' => '仓库简称', 'created_at_raw' => '创建时间(实际)',
'express_name' => '快递名称', 'express_no' => '快递单号', 'not_sent' => '未发出', 'owner_name' => '负责人',
];
$query = DB::connection($connection)->table('inventory_inbound_detail_rows');
$month = substr(trim((string) $request->query('detail_month', '')), 0, 10);
if ($month !== '') {
$query->where('report_month', $month);
}
$warehouse = trim((string) $request->query('detail_warehouse', ''));
if ($warehouse !== '') {
$like = '%' . addcslashes($warehouse, '%_\\') . '%';
$query->where(function ($inner) use ($like): void {
$inner->where('warehouse', 'like', $like)->orWhere('warehouse_short_name', 'like', $like);
});
}
$barcode = trim((string) $request->query('detail_barcode', ''));
if ($barcode !== '') {
$query->where('barcode', 'like', '%' . addcslashes($barcode, '%_\\') . '%');
}
$filename = '入库明细数据_' . ($month !== '' ? $month : 'all') . '.csv';
return response()->streamDownload(function () use ($query, $labels): void {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, array_values($labels));
$query->orderBy('row_key')->chunk(1000, function ($rows) use ($out, $labels): void {
foreach ($rows as $row) {
$line = [];
foreach (array_keys($labels) as $column) {
if ($column === 'total') {
// 总计 = 金额 + 预估金额
$line[] = number_format((float) ($row->amount ?? 0) + (float) ($row->estimated_amount ?? 0), 2);
continue;
}
$value = $row->{$column} ?? '';
$line[] = is_string($value) ? $value : (string) $value;
}
fputcsv($out, $line);
}
});
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
protected function viewData(
array $section,
?array $page = null,
$tabs = null,
?array $activeTab = null,
?array $content = null,
): array {
$descriptor = null;
$rows = collect();
$tabValue = null;
$tabColumn = null;
$upstreamError = null;
$inventoryWarehouseMappings = collect();
$receiptStats = null;
$agreementCourierNames = $this->agreementCourierNames($page['slug'] ?? null);
$agreementCourierAccounts = $this->agreementCourierAccounts($page['slug'] ?? null, $agreementCourierNames);
// 内部流转报表读取共享业务镜像中由工作簿导入的数据集,不走上游只读查询。
if ($page && $content === null && $this->isInternalFlowReportPage($section, $page)) {
[$rows, $upstreamError] = $this->internalFlowReportRows();
}
if (($section['slug'] ?? null) === 'product-inventory' && ($page['slug'] ?? null) === 'inventory-report') {
if (request()->boolean('mapping')) {
$rows = new LengthAwarePaginator([], 0, 1, 1, [
'path' => request()->url(),
'query' => request()->query(),
]);
} else {
[$rows, $upstreamError] = $this->inventoryReportRows();
}
$inventoryWarehouseMappings = $this->inventoryWarehouseMappingRows();
}
if (($section['slug'] ?? null) === 'product-inventory'
&& ($page['slug'] ?? null) === 'monthly-weighted-average-price') {
$rows = $this->monthlyWeightedAveragePriceRows();
}
if (($section['slug'] ?? null) === 'financial-operations'
&& ($page['slug'] ?? null) === 'performance-details') {
[$rows, $receiptStats] = $this->storeReceiptOrderMatchRows();
}
if (($section['slug'] ?? null) === 'financial-operations'
&& ($page['slug'] ?? null) === 'courier-freight') {
$rows = $this->courierFreightRows();
}
if ($page && $content === null) {
$descriptor = TableRegistry::describe(
$section['slug'],
$page['slug'],
$activeTab['slug'] ?? null,
);
if ($descriptor) {
if ($descriptor->readOnly) {
try {
$rows = app(UpstreamBusinessInformation::class)->paginate(
$section['slug'],
$page['slug'],
50,
);
} catch (ServiceUnavailableHttpException $exception) {
// 首份快照尚未生成或某个来源暂时不可用时,保留页面、
// 交代原因并允许后台刷新,而不是把整个业务页变成通用 503。
$rows = new LengthAwarePaginator([], 0, 50, 1, [
'path' => request()->url(),
'query' => request()->query(),
]);
$upstreamError = $exception->getMessage();
}
return [
'section' => $section,
'page' => $page,
'tabs' => $tabs ?? collect(),
'activeTab' => $activeTab,
'content' => $content,
'descriptor' => $descriptor,
'rows' => $rows,
'upstreamError' => $upstreamError,
'tabColumn' => null,
'tabValue' => null,
'edgeBanding' => $this->edgeBandingContent($page['slug']),
'inventoryData' => $this->inventoryContent($page['slug']),
'rawBoardData' => $this->rawBoardContent($page['slug']),
'agreementTasks' => $this->agreementTasks($page['slug']),
'agreementQuoteSignatures' => $this->agreementQuoteSignatures($page['slug']),
'agreementSubjects' => $this->agreementSubjects($page['slug']),
'agreementCourierNames' => $agreementCourierNames,
'agreementCourierAccounts' => $agreementCourierAccounts,
'agreementQuoteVersions' => $this->agreementQuoteVersions($page['slug']),
'inventoryWarehouseMappings' => $inventoryWarehouseMappings,
'receiptStats' => $receiptStats,
];
}
if (($section['slug'] ?? null) === 'product-inventory'
&& ($page['slug'] ?? null) === 'inventory-report') {
// inventoryReportRows() 已从聚水潭共享镜像按全仓口径取数。
// 这里必须保留该结果,不能再被目录表的旧演示数据覆盖。
} elseif ($this->isStoreReceivingAccountPage($section['slug'], $page['slug'])) {
try {
app(\App\Business\StoreReceivingAccountSynchronizer::class)->sync((int) (company_id() ?: 1));
} catch (Throwable $exception) {
report($exception);
$upstreamError = '店铺基础信息暂时无法同步,请稍后重试。';
}
$rows = $this->storeReceivingAccountRows($activeTab['slug'] ?? null);
$tabColumn = 'store_platform';
} else {
$query = ($descriptor->model)::query();
if ($descriptor->tabFilterColumn && $activeTab) {
$tabValue = TableRegistry::tabValue($section['slug'], $page['slug'], $activeTab['slug']);
if ($tabValue !== null) {
$query->where($descriptor->tabFilterColumn, $tabValue);
}
$tabColumn = $descriptor->tabFilterColumn;
}
foreach (TableRegistry::fixedFilters($section['slug'], $page['slug']) as $column => $value) {
$query->where($column, $value);
}
$rows = $query->orderByDesc('id')->paginate(50)->withQueryString();
}
}
}
return [
'section' => $section,
'page' => $page,
'tabs' => $tabs ?? collect(),
'activeTab' => $activeTab,
'content' => $content,
'descriptor' => $descriptor,
'rows' => $rows,
'upstreamError' => $upstreamError,
'tabColumn' => $tabColumn,
'tabValue' => $tabValue,
'edgeBanding' => $page ? $this->edgeBandingContent($page['slug']) : null,
'inventoryData' => $page ? $this->inventoryContent($page['slug']) : null,
'rawBoardData' => $page ? $this->rawBoardContent($page['slug']) : null,
'agreementTasks' => $this->agreementTasks($page['slug'] ?? null),
'agreementQuoteSignatures' => $this->agreementQuoteSignatures($page['slug'] ?? null),
'agreementSubjects' => $this->agreementSubjects($page['slug'] ?? null),
'agreementCourierNames' => $agreementCourierNames,
'agreementCourierAccounts' => $agreementCourierAccounts,
'agreementQuoteVersions' => $this->agreementQuoteVersions($page['slug'] ?? null),
'inventoryWarehouseMappings' => $inventoryWarehouseMappings,
'receiptStats' => $receiptStats,
'payablesFinal' => $this->payablesFinalData($section, $page),
];
}
/**
* 「终版」目录的按表数据:独立存放,初始为空,由页面上的导入/导出/查询/编辑维护。
*
* @return array<string,mixed>|null
*/
protected function payablesFinalData(array $section, ?array $page): ?array
{
$sectionSlug = (string) ($section['slug'] ?? '');
if (empty($page) || ! in_array($sectionSlug, [
PayablesFinalRegistry::SECTION_SUPPLIER,
PayablesFinalRegistry::SECTION_COURIER,
], true)) {
return null;
}
$dataset = (string) ($page['slug'] ?? '');
$registry = app(PayablesFinalRegistry::class);
if (! $registry->supports($sectionSlug, $dataset)) {
return null;
}
$keyword = trim((string) request('keyword', ''));
$rows = $this->supplierPayableOverrides->listRows(
PayablesFinalActions::DATASET_PREFIX
. ($sectionSlug === PayablesFinalRegistry::SECTION_COURIER ? 'courier:' : 'supplier:')
. $dataset,
(int) (company_id() ?: 1),
);
if ($keyword !== '') {
$needle = mb_strtolower($keyword);
$rows = array_values(array_filter($rows, static function (array $row) use ($needle): bool {
foreach ($row as $key => $value) {
if ($key === '_action_key' || $value === null) {
continue;
}
if (str_contains(mb_strtolower((string) $value), $needle)) {
return true;
}
}
return false;
}));
}
$perPage = 50;
$currentPage = max((int) request('page', 1), 1);
return [
'dataset' => $dataset,
'fields' => collect($registry->fields($sectionSlug, $dataset))
->map(static fn (array $definition, string $name): array => ['name' => $name] + $definition)
->values()
->all(),
'rows' => new LengthAwarePaginator(
array_slice($rows, ($currentPage - 1) * $perPage, $perPage),
count($rows),
$perPage,
$currentPage,
['path' => request()->url(), 'query' => request()->query()],
),
'keyword' => $keyword,
'actions' => url('/' . (company_id() ?: 1) . '/workspace/' . $sectionSlug . '/actions/' . $dataset),
];
}
/** @return array{0:LengthAwarePaginator,1:?string} */
private function inventoryReportRows(): array
{
$page = max(1, request()->integer('page', 1));
$perPage = min(max((int) setting('default.list_limit', 25), 1), 100);
$filters = $this->inventoryReportFilters(request());
$error = null;
try {
if (! $this->thirdPartyWarehouseStorage->available()) {
$error = '进销存共享镜像尚未建立,暂时无法读取。';
} elseif (! $this->thirdPartyWarehouseStorage->allWarehousesReady()) {
$error = '进销存全仓库共享镜像正在执行首次同步,完成前不会展示不完整的单仓数据。';
} else {
$result = $this->thirdPartyWarehouseStorage->paginateMonthlyInventoryReport(
$filters,
$page,
$perPage,
);
$shortNames = app(InventoryWarehouseShortNames::class);
$names = $shortNames->forCompany((int) (company_id() ?: 1));
return [new LengthAwarePaginator(
array_map(static fn (object $row): object => $shortNames->fill($row, $names), $result['data']),
$result['total'],
$perPage,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
), null];
}
} catch (Throwable $exception) {
report($exception);
$error = '进销存共享镜像暂时无法读取,请稍后重试。';
}
return [new LengthAwarePaginator([], 0, $perPage, $page, [
'path' => request()->url(),
'query' => request()->except('page'),
]), $error];
}
private function inventoryWarehouseMappingRows(): LengthAwarePaginator
{
$query = InventoryWarehouseNameMapping::query()->orderBy('warehouse_provider')->orderBy('warehouse');
$provider = mb_substr(trim((string) request('mapping_warehouse_provider', '')), 0, 191);
$warehouse = mb_substr(trim((string) request('mapping_warehouse', '')), 0, 191);
if ($provider !== '') {
$query->where('warehouse_provider', 'like', '%' . addcslashes($provider, '%_\\') . '%');
}
if ($warehouse !== '') {
$query->where('warehouse', 'like', '%' . addcslashes($warehouse, '%_\\') . '%');
}
return $query->paginate(50)->withQueryString();
}
public function storeInventoryWarehouseMapping(Request $request)
{
$data = $request->validate([
'warehouse_provider' => ['required', 'string', 'max:191'],
'warehouse' => ['required', 'string', 'max:191'],
'warehouse_short_name' => ['nullable', 'string', 'max:191'],
]);
$data['warehouse_provider'] = trim($data['warehouse_provider']);
$data['warehouse'] = trim($data['warehouse']);
$data['warehouse_short_name'] = trim((string) ($data['warehouse_short_name'] ?? ''))
?: InventoryWarehouseNameMappingWorkbookImporter::suggestion($data['warehouse_provider'], $data['warehouse']);
if (InventoryWarehouseNameMapping::query()
->where('warehouse_provider', $data['warehouse_provider'])
->where('warehouse', $data['warehouse'])
->exists()) {
return back()->withInput()->withErrors(['warehouse' => '相同“仓储方 + 仓库”已存在,请使用修改功能。']);
}
InventoryWarehouseNameMapping::create($data + [
'company_id' => company_id() ?: 1,
'created_by' => auth()->id(),
'updated_by' => auth()->id(),
]);
return $this->inventoryWarehouseMappingRedirect()->with('success', '仓库名称映射已新增。');
}
public function updateInventoryWarehouseMapping(Request $request, int $id)
{
$mapping = InventoryWarehouseNameMapping::query()->findOrFail($id);
$data = $request->validate([
'warehouse_provider' => ['required', 'string', 'max:191'],
'warehouse' => ['required', 'string', 'max:191'],
'warehouse_short_name' => ['required', 'string', 'max:191'],
]);
$data = array_map(static fn (string $value): string => trim($value), $data);
if (InventoryWarehouseNameMapping::query()
->where('id', '!=', $mapping->id)
->where('warehouse_provider', $data['warehouse_provider'])
->where('warehouse', $data['warehouse'])
->exists()) {
return back()->withInput()->withErrors(['warehouse' => '相同“仓储方 + 仓库”已存在,不能产生重复台账。']);
}
$mapping->update($data + ['updated_by' => auth()->id()]);
return $this->inventoryWarehouseMappingRedirect()->with('success', '仓库名称映射已修改。');
}
public function previewInventoryWarehouseMappingImport(
Request $request,
InventoryWarehouseNameMappingWorkbookImporter $importer,
) {
$request->validate([
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240'],
]);
$preview = $importer->preview($request->file('import'));
$token = Str::random(40);
session()->put('inventory_warehouse_mapping_previews.' . $token, $preview);
return $this->inventoryWarehouseMappingRedirect()
->with('warehouse_mapping_preview', $preview + ['token' => $token]);
}
public function commitInventoryWarehouseMappingImport(
Request $request,
InventoryWarehouseNameMappingWorkbookImporter $importer,
) {
$token = (string) $request->input('preview_token');
$preview = session()->pull('inventory_warehouse_mapping_previews.' . $token);
abort_if($token === '' || ! is_array($preview), 422, '导入预览已过期,请重新上传文件。');
$result = $importer->commit($preview);
return $this->inventoryWarehouseMappingRedirect()->with(
'success',
"导入完成:新增 {$result['created']} 条,跳过 {$result['skipped']} 条重复映射。",
);
}
public function exportInventoryWarehouseMappings(Request $request): StreamedResponse
{
$query = InventoryWarehouseNameMapping::query()->orderBy('warehouse_provider')->orderBy('warehouse');
$provider = mb_substr(trim((string) $request->query('mapping_warehouse_provider', '')), 0, 191);
$warehouse = mb_substr(trim((string) $request->query('mapping_warehouse', '')), 0, 191);
if ($provider !== '') {
$query->where('warehouse_provider', 'like', '%' . addcslashes($provider, '%_\\') . '%');
}
if ($warehouse !== '') {
$query->where('warehouse', 'like', '%' . addcslashes($warehouse, '%_\\') . '%');
}
return response()->streamDownload(function () use ($query): void {
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('仓库名称映射表');
$headers = ['仓储方', '仓库', '仓库简称'];
$sheet->fromArray($headers, null, 'A1');
$lastColumn = Coordinate::stringFromColumnIndex(count($headers));
$headerRange = "A1:{$lastColumn}1";
$sheet->getStyle($headerRange)->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension(1)->setRowHeight(24);
$sheet->freezePane('A2');
$sheet->setAutoFilter($headerRange);
foreach ([20, 32, 20] as $index => $width) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
}
$rowNumber = 2;
foreach ($query->cursor() as $mapping) {
$sheet->fromArray([
$mapping->warehouse_provider,
$mapping->warehouse,
$mapping->warehouse_short_name,
], null, "A{$rowNumber}");
$rowNumber++;
}
(new Xlsx($spreadsheet))->save('php://output');
$spreadsheet->disconnectWorksheets();
}, '仓库名称映射表_' . now()->format('Ymd_His') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store, no-cache, must-revalidate',
]);
}
private function inventoryWarehouseMappingRedirect()
{
return redirect()->route('business.pages.show', [
'company_id' => company_id() ?: 1,
'section' => 'product-inventory',
'page' => 'inventory-report',
'mapping' => 1,
'mapping_warehouse_provider' => request('mapping_warehouse_provider', ''),
'mapping_warehouse' => request('mapping_warehouse', ''),
]);
}
/** @return array<string,string> */
private function inventoryReportFilters(Request $request): array
{
$month = trim((string) $request->query('month', now()->startOfMonth()->subMonth()->format('Y-m')));
if (preg_match('/^(\d{4})-(\d{2})$/', $month, $matches) !== 1
|| ! checkdate((int) $matches[2], 1, (int) $matches[1])) {
$month = now()->startOfMonth()->subMonth()->format('Y-m');
}
$minimumMonth = substr(ThirdPartyWarehouseStorageMirrorRepository::INVENTORY_REPORT_START_DATE, 0, 7);
if ($month < $minimumMonth) {
$month = $minimumMonth;
}
return [
'month' => $month,
'product_code' => mb_substr(trim((string) $request->query('product_code', '')), 0, 100),
'warehouse_provider' => mb_substr(trim((string) $request->query('warehouse_provider', '')), 0, 191),
'category' => mb_substr(trim((string) $request->query('category', '')), 0, 191),
];
}
private function isStoreReceivingAccountPage(string $section, string $page): bool
{
return $section === 'fund-management' && $page === 'store-receiving-accounts';
}
private function isInternalFlowReportPage(array $section, array $page): bool
{
return ($section['slug'] ?? null) === 'store-management'
&& ($page['slug'] ?? null) === 'internal-flow-report';
}
/**
* 内部流转报表明细。数据来自共享业务镜像中按工作簿整份替换的导入结果,
* 尚未导入时保留空页面并说明原因,不把“未导入”当成业务数据为 0。
*
* @return array{0: LengthAwarePaginator, 1: ?string}
*/
private function internalFlowReportRows(): array
{
$perPage = 50;
$currentPage = max((int) request('page', 1), 1);
$paginate = fn (array $items, int $total): LengthAwarePaginator => new LengthAwarePaginator(
$items,
$total,
$perPage,
$currentPage,
['path' => request()->url(), 'query' => request()->query()],
);
$repository = app(InternalFlowReportRepository::class);
if (! $repository->ready()) {
return [
$paginate([], 0),
'内部流转数据尚未导入。导入《一般纳税人内部流转》工作簿后,本页会显示明细。',
];
}
$payload = $repository->paginate(
$currentPage,
$perPage,
[
'company_name' => (string) request('company_name', ''),
'tag' => (string) request('tag', ''),
],
request('sort') === null ? null : (string) request('sort'),
(string) request('direction', 'asc'),
);
return [$paginate($payload['data'], $payload['total']), null];
}
private function storeReceivingAccountRows(?string $tab): LengthAwarePaginator
{
$query = \App\Models\Business\StoreReceivingAccount::query();
$platforms = $tab === null
? ['淘宝', '阿里巴巴', '天猫', '淘宝分销', '唯品会', '京东', '京喜', '抖音', '拼多多']
: TableRegistry::tabValues('fund-management', 'store-receiving-accounts', $tab);
if ($platforms !== []) {
$query->whereIn('store_platform', $platforms);
}
$erpName = mb_substr(trim((string) request('erp_store_name', '')), 0, 191);
if ($erpName !== '') {
$query->where('store_name', 'like', '%' . addcslashes($erpName, '%_\\') . '%');
}
$rows = $query->orderByDesc('id')->get();
$shopName = mb_substr(trim((string) request('shop_name', '')), 0, 191);
$status = trim((string) request('store_status', ''));
if ($shopName !== '' || $status !== '') {
$rows = $rows->filter(function ($row) use ($shopName, $status): bool {
return ($shopName === '' || str_contains((string) $row->shop_name, $shopName))
&& ($status === '' || (string) $row->store_status === $status);
})->values();
}
$page = max((int) request('page', 1), 1);
$perPage = 50;
$total = $rows->count();
return new LengthAwarePaginator(
$rows->forPage($page, $perPage)->values(),
$total,
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()],
);
}
protected function agreementCourierNames(?string $page): Collection
{
if ($page !== 'agreement-signing') {
return collect();
}
try {
return collect($this->courierInformation->names());
} catch (Throwable $exception) {
// 快递镜像不可用时仍允许打开签署页,并明确展示暂无可选资料。
report($exception);
return collect();
}
}
/** @return array<string,string> */
protected function agreementCourierAccounts(?string $page, ?Collection $courierNames = null): array
{
if ($page !== 'agreement-signing') {
return [];
}
try {
return $this->courierInformation->signingAccountsByName(
($courierNames ?? $this->agreementCourierNames($page))->values()->all(),
);
} catch (Throwable $exception) {
// 快递镜像不可用时仍可手工填写 E 签宝账号,不能阻断签署页。
report($exception);
return [];
}
}
protected function agreementTasks(?string $page): Collection|\Illuminate\Contracts\Pagination\LengthAwarePaginator
{
if ($page !== 'agreement-signing') {
return collect();
}
try {
$type = $this->agreementFilterType();
if ($type === 'quote-confirmation') {
return collect();
}
$query = CourierAgreementSignature::query();
$search = $this->agreementFilterSearch();
$status = $this->agreementFilterStatus();
if ($search !== '') {
$like = '%' . addcslashes($search, '%_\\') . '%';
$query->where(function ($builder) use ($like): void {
$builder
->where('business_no', 'like', $like)
->orWhere('courier_name', 'like', $like)
->orWhere('organization_name', 'like', $like)
->orWhere('operator_name', 'like', $like)
->orWhere('company_full_name', 'like', $like);
});
}
if ($status !== null) {
$query->where('status', $status);
}
if ($type !== null) {
$query->where('document_type', $type);
}
return $query
->latest('id')
->paginate(50, ['*'], 'agreement_task_page')->withQueryString();
} catch (QueryException $exception) {
// 迁移尚未执行时仍允许打开页面;发送接口会明确提示数据库未就绪。
report($exception);
return collect();
}
}
protected function agreementFilterSearch(): string
{
return mb_substr(trim((string) request('agreement_search', '')), 0, 191);
}
protected function agreementFilterStatus(): ?string
{
$status = trim((string) request('agreement_status', ''));
$allowed = [
CourierAgreementSignature::STATUS_PREPARED,
CourierAgreementSignature::STATUS_SENDING,
CourierAgreementSignature::STATUS_SIGNING,
CourierAgreementSignature::STATUS_COMPLETED,
CourierAgreementSignature::STATUS_ARCHIVED,
CourierAgreementSignature::STATUS_REVOKED,
CourierAgreementSignature::STATUS_EXPIRED,
CourierAgreementSignature::STATUS_REJECTED,
CourierAgreementSignature::STATUS_FAILED,
CourierAgreementSignature::STATUS_UNKNOWN,
];
return in_array($status, $allowed, true) ? $status : null;
}
protected function agreementFilterType(): ?string
{
$type = trim((string) request('agreement_type', ''));
$allowed = array_keys(CourierAgreementSignature::DOCUMENTS);
return in_array($type, $allowed, true) ? $type : null;
}
protected function agreementSubjects(?string $page): Collection
{
if ($page !== 'agreement-signing') {
return collect();
}
try {
return CourierSigningSubject::query()
->orderByDesc('is_default')
->orderBy('name')
->get();
} catch (QueryException $exception) {
// 主体管理迁移尚未执行时仍允许打开签署页面,默认使用当前公司名称。
report($exception);
return collect();
}
}
protected function agreementQuoteSignatures(?string $page): Collection|\Illuminate\Contracts\Pagination\LengthAwarePaginator
{
if ($page !== 'agreement-signing') {
return collect();
}
try {
$type = $this->agreementFilterType();
if ($type !== null && $type !== 'quote-confirmation') {
return collect();
}
$query = CourierQuoteSignature::query()->with('version');
$search = $this->agreementFilterSearch();
$status = $this->agreementFilterStatus();
if ($search !== '') {
$like = '%' . addcslashes($search, '%_\\') . '%';
$query->where(function ($builder) use ($like): void {
$builder
->where('organization_name', 'like', $like)
->orWhere('operator_name', 'like', $like)
->orWhereHas('version', function ($versionQuery) use ($like): void {
$versionQuery
->where('version_code', 'like', $like)
->orWhere('carrier_name', 'like', $like);
});
});
}
if ($status !== null) {
$query->where('status', $status);
}
return $query
->latest('id')
->paginate(50, ['*'], 'agreement_quote_page')->withQueryString();
} catch (QueryException $exception) {
// 报价签署迁移尚未执行时仍允许打开协议签署页。
report($exception);
return collect();
}
}
protected function agreementQuoteVersions(?string $page): Collection
{
if ($page !== 'agreement-signing') {
return collect();
}
try {
return CourierQuoteVersion::query()
->where('status', 'approved')
->orderByDesc('effective_date')
->orderByDesc('id')
->limit(100)
->get();
} catch (QueryException $exception) {
// 报价版本迁移尚未执行时仍允许打开协议签署页,报价确认单会提示暂无可选版本。
report($exception);
return collect();
}
}
protected function resolveContentForSection(string $section, string $page, ?string $tab): ?array
{
if (in_array($section, self::PAYABLES_FINAL_SECTIONS, true)) {
return null;
}
return $this->resolveContent($page, $tab);
}
protected function resolveContent(string $page, ?string $tab): ?array
{
return match ($page) {
'marketing-account-bills' => $this->contentForMarketingAccountBills($tab),
'marketing-account-balance' => $this->contentForMarketingAccountBalance($tab),
'jingxi-refund-data' => $this->contentForJingxiRefundData(),
'non-jingxi-refund-data' => $this->contentForNonJingxiRefundData(),
'small-payment-work-orders' => $this->contentForSmallPaymentWorkOrders(),
'zhongcai-reconciliation' => $this->contentForZhongcaiReconciliation(),
'normal-products' => $this->contentForNormalProducts(),
'product-prices' => $this->contentForProductPrices(),
'courier-information' => $this->contentForCourierInformation(),
'freight-reconciliation' => $this->contentForFreightReconciliation(),
'third-party-warehouse-storage' => $this->contentForThirdPartyWarehouseStorage(),
'board-deductions' => $this->contentForBoardDeductions(),
'supplier-payables-report' => $this->contentForSupplierPayablesReport(),
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
'online-daifa-price' => $this->contentForOnlineDaifaPrice(),
'product-costs' => $this->contentForProductCosts(),
'courier-surcharge' => $this->contentForCourierSurcharge(),
'freight-difference', 'other-surcharge-summary', 'return-fee' => $this->contentForCourierPayableSummary($page),
'shipping-data' => $this->contentForShippingData(),
'return-pickup-management' => $this->contentForReturnPickupManagement(),
'withdrawal-management' => $this->contentForWithdrawalManagement(),
'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(),
'account-mapping' => $this->contentForAccountMapping(),
'billing-details' => app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class)->page(),
'store-violation-alert' => $this->contentForStoreViolationAlerts(),
'planting-details' => $this->contentForPlantingDetails(),
'jst-order-labels' => $this->contentForJstOrderLabels(),
'store-report-data-logic' => $this->prepareStoreReportLogic(),
'tax-forecast-data-logic' => $this->contentForTaxForecastLogic(),
default => null,
};
}
/**
* 税金预测表逻辑配置:规则清单、数据源状态和运行参数。
*/
protected function contentForTaxForecastLogic(): array
{
return [
'view' => 'business.pages.company-management.tax-forecast-data-logic',
'taxRules' => TaxForecastLogic::rules(),
'taxSettings' => TaxForecastLogic::current(),
'taxHealth' => TaxForecastLogic::health(),
];
}
public function editTaxForecastLogic()
{
return view('business.pages.company-management.tax-forecast-data-logic-edit', [
'section' => ['slug' => 'trademark-management', 'title' => '商标管理'],
'page' => ['slug' => 'tax-forecast-data-logic', 'title' => '税金预测表逻辑配置'],
'settings' => TaxForecastLogic::current(),
'incomeForecastSources' => TaxForecastLogic::INCOME_FORECAST_SOURCES,
]);
}
public function updateTaxForecastLogic(Request $request)
{
$data = $request->validate([
'vat_small_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'vat_free_threshold' => ['required', 'numeric', 'min:0'],
'vat_general_enabled' => ['required', 'in:0,1'],
'surtax_city_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'surtax_education_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'surtax_local_education_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'surtax_halved' => ['required', 'in:0,1'],
'stamp_duty_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'stamp_duty_halved' => ['required', 'in:0,1'],
'cit_effective_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'cit_threshold' => ['required', 'numeric', 'min:0'],
'individual_halved_threshold' => ['required', 'numeric', 'min:0'],
'assessed_income_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'income_forecast_source' => ['required', 'in:' . implode(',', array_keys(TaxForecastLogic::INCOME_FORECAST_SOURCES))],
]);
TaxForecastLogic::save($data);
return redirect()
->route('business.pages.show', ['section' => 'trademark-management', 'page' => 'tax-forecast-data-logic'])
->with('success', '税金预测表逻辑配置已保存,下次预测按新参数计算。');
}
/** 首次打开店铺维度报表逻辑页时把代码里的期初数据固化成可编辑设置。 */
protected function prepareStoreReportLogic(): ?array
{
StoreReportInitialPeriods::seedFromConfig();
return null;
}
/** 店铺维度报表逻辑配置:期初主体数据编辑页。 */
public function editStoreReportLogic()
{
$periods = StoreReportInitialPeriods::current();
$shops = $periods['shops'];
uasort($shops, static fn (array $left, array $right): int => strcmp($left['changed_at'], $right['changed_at']));
return view('business.pages.trademark-management.store-report-data-logic-edit', [
'section' => ['slug' => 'trademark-management', 'title' => '商标管理'],
'page' => ['slug' => 'store-report-data-logic', 'title' => '店铺维度报表逻辑配置'],
'initialEffectiveAt' => $periods['initial_effective_at'],
'initialShops' => $shops,
]);
}
public function updateStoreReportInitialPeriod(Request $request)
{
$data = $request->validate([
'store_name' => ['required', 'string', 'max:191'],
'previous_company' => ['required', 'string', 'max:191'],
'company' => ['required', 'string', 'max:191'],
'changed_at' => ['required', 'date_format:Y-m-d'],
]);
StoreReportInitialPeriods::putShop($data['store_name'], [
'previous_company' => $data['previous_company'],
'company' => $data['company'],
'changed_at' => $data['changed_at'],
]);
return redirect()
->route('business.store-report-logic.edit')
->with('success', sprintf('店铺「%s」的期初主体已保存。', $data['store_name']));
}
public function destroyStoreReportInitialPeriod(Request $request)
{
$data = $request->validate([
'store_name' => ['required', 'string', 'max:191'],
]);
StoreReportInitialPeriods::forgetShop($data['store_name']);
return redirect()
->route('business.store-report-logic.edit')
->with('success', sprintf('店铺「%s」的期初主体已删除。', $data['store_name']));
}
/**
* 账单流水数据 › 聚水潭订单标签明细。
*
* 按标签拆行展示(一个标签一行),不做标签汇总、不对订单去重。
*/
protected function contentForJstOrderLabels(): array
{
$request = request();
$filters = $this->jstOrderLabelFilters($request);
$query = $this->jstOrderLabelQuery($filters);
$limit = min(max((int) setting('default.list_limit', 25), 10), 100);
$page = max((int) $request->query('page', 1), 1);
$hasFilters = $filters['date_from'] !== ''
|| $filters['date_to'] !== ''
|| $filters['shop_names'] !== []
|| $filters['labels'] !== []
|| $filters['keyword'] !== '';
// 无筛选时复用缓存总数,避免每次翻页都在 233 万行上重跑 COUNT(*)。
$total = $hasFilters
? (clone $query)->count()
: app(\App\Business\JstOrderLabelOptions::class)->total((int) company_id());
$records = new \Illuminate\Pagination\LengthAwarePaginator(
$query->orderByDesc('ordered_at')->orderByDesc('id')->forPage($page, $limit)->get(),
$total,
$limit,
$page,
['path' => $request->url(), 'query' => $request->query()],
);
// 「是否种菜订单」:按(公司 + 标签)匹配财务维护的映射表,命中为是、未命中为否
$plantingRules = app(\App\Business\JstOrderLabelPlantingRules::class);
$records->setCollection($records->getCollection()->map(static function ($record) use ($plantingRules) {
$record->setAttribute('is_planting_order', $plantingRules->flag((int) company_id(), $record->label));
return $record;
}));
return [
'view' => 'business.pages.billing-transactions.jst-order-labels',
'records' => $records,
'filters' => $filters,
'totalRows' => $records->total(),
'filterOptions' => [
'shops' => app(\App\Business\JstOrderLabelOptions::class)->shops((int) company_id()),
'labels' => app(\App\Business\JstOrderLabelOptions::class)->labels((int) company_id()),
],
];
}
/** @return array{date_from:string,date_to:string,shop_names:list<string>,labels:list<string>,keyword:string} */
private function jstOrderLabelFilters(Request $request): array
{
return [
'date_from' => $this->dateFilter($request->query('date_from')),
'date_to' => $this->dateFilter($request->query('date_to')),
'shop_names' => $this->filterValues($request->query('shop_name'), 191),
'labels' => $this->filterValues($request->query('label'), 191),
'keyword' => mb_substr(trim((string) $request->query('keyword')), 0, 100),
];
}
/**
* 多选筛选值归一化:兼容旧的单值参数(?shop_name=A)与新的数组参数(?shop_name[]=A&shop_name[]=B)。
*
* @return list<string>
*/
// 上限与 JstOrderLabelOptions::LIMIT 对齐:下拉里能选到的值,提交时不能被悄悄截断。
private function filterValues(mixed $raw, int $maxLength, int $limit = 5000): array
{
$list = is_array($raw) ? $raw : [$raw];
$values = [];
foreach ($list as $value) {
$value = mb_substr(trim((string) $value), 0, $maxLength);
if ($value !== '' && ! in_array($value, $values, true)) {
$values[] = $value;
}
}
return array_slice($values, 0, $limit);
}
/** @param array{date_from:string,date_to:string,shop_names:list<string>,labels:list<string>,keyword:string} $filters */
private function jstOrderLabelQuery(array $filters): \Illuminate\Database\Eloquent\Builder
{
$query = \App\Models\Business\JstOrderLabel::query()->where('company_id', company_id());
if ($filters['date_from'] !== '') {
$query->where('ordered_at', '>=', $filters['date_from'] . ' 00:00:00');
}
if ($filters['date_to'] !== '') {
$query->where('ordered_at', '<', date('Y-m-d', strtotime($filters['date_to'] . ' +1 day')) . ' 00:00:00');
}
if ($filters['shop_names'] !== []) {
$query->whereIn('shop_name', $filters['shop_names']);
}
if ($filters['labels'] !== []) {
// 多选标签按「任一命中」过滤,保持与单值时一致的包含匹配语义
$query->where(function ($sub) use ($filters): void {
foreach ($filters['labels'] as $label) {
$sub->orWhere('label', 'like', '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $label) . '%');
}
});
}
if ($filters['keyword'] !== '') {
$query->where('order_no', 'like', '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['keyword']) . '%');
}
return $query;
}
/** 账单流水数据 › 聚水潭订单标签明细导出(按当前筛选,全量) */
public function exportJstOrderLabels(Request $request): StreamedResponse
{
$query = $this->jstOrderLabelQuery($this->jstOrderLabelFilters($request));
$plantingRules = app(\App\Business\JstOrderLabelPlantingRules::class);
$companyId = (int) company_id();
return response()->streamDownload(function () use ($query, $plantingRules, $companyId): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建聚水潭订单标签明细导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, ['线上订单号', '店铺名称', '下单时间', '是否种菜订单', '标签']);
$query->orderBy('id')->chunkById(2000, function ($rows) use ($csv, $plantingRules, $companyId): void {
foreach ($rows as $row) {
fputcsv($csv, [
$row->order_no,
$row->shop_name,
$row->ordered_at?->format('Y-m-d H:i:s'),
$plantingRules->flag($companyId, $row->label),
$row->label,
]);
}
});
fclose($csv);
}, '聚水潭订单标签明细_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/**
* 财务经营风险管控 › 店铺违规信息预警。
*
* 六个平台页签共用统一表头;数据来自统一表 store_violation_records
* (上游抓取同步 + 手工导入),页面不直接读取上游。
*/
protected function contentForStoreViolationAlerts(): array
{
$request = request();
$platforms = (array) config('store_violations.platforms', []);
$requested = (string) $request->query('platform', (string) array_key_first($platforms));
$activePlatform = array_key_exists($requested, $platforms) ? $requested : (string) array_key_first($platforms);
$filters = $this->storeViolationFilters($request);
$repository = app(StoreViolationRecordRepository::class);
$limit = min(max((int) setting('default.list_limit', 25), 10), 100);
$error = null;
$panels = [];
if (! $repository->available()) {
$error = '店铺违规统一表尚未建立,暂时无法读取数据。';
}
foreach (array_keys($platforms) as $slug) {
$page = $slug === $activePlatform ? max((int) $request->query('page', 1), 1) : 1;
$records = Collection::make();
$total = 0;
if ($error === null) {
try {
$result = $repository->paginate((string) $slug, $filters, $page, $limit);
$records = Collection::make($result['data']);
$total = (int) $result['total'];
} catch (Throwable $exception) {
report($exception);
$error = '店铺违规数据暂时无法读取,请稍后重试。';
}
}
$panels[$slug] = [
'rows' => new LengthAwarePaginator($records, $total, $limit, $page, [
'path' => $request->url(),
'query' => array_merge($request->query(), ['platform' => (string) $slug]),
]),
'total' => $total,
];
}
return [
'view' => 'business.pages.financial-risk-control.index',
'storeViolationPlatforms' => $platforms,
'storeViolationActivePlatform' => $activePlatform,
'storeViolationPanels' => $panels,
'storeViolationFilters' => $filters,
'storeViolationFilterOptions' => $error === null
? $repository->filterOptions($activePlatform)
: ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => []],
'storeViolationPlatformCounts' => $error === null ? $repository->platformCounts() : [],
'storeViolationSync' => $repository->syncStatus(),
'storeViolationError' => $error,
'storeViolationPreview' => session('store_violation_preview'),
];
}
/** @return array<string,string> */
private function storeViolationFilters(Request $request): array
{
return [
'keyword' => mb_substr(trim((string) $request->query('keyword')), 0, 100),
'status' => mb_substr(trim((string) $request->query('status')), 0, 64),
'handling_status' => mb_substr(trim((string) $request->query('handling_status')), 0, 32),
'category' => mb_substr(trim((string) $request->query('category')), 0, 64),
'date_from' => $this->dateFilter($request->query('date_from')),
'date_to' => $this->dateFilter($request->query('date_to')),
];
}
/** 店铺违规信息预警:按当前筛选导出统一表头(Excel)。 */
public function exportStoreViolations(Request $request): StreamedResponse
{
$platforms = (array) config('store_violations.platforms', []);
$platform = (string) $request->query('platform', (string) array_key_first($platforms));
abort_unless(array_key_exists($platform, $platforms), 404);
$repository = app(StoreViolationRecordRepository::class);
abort_unless($repository->available(), 503, '店铺违规统一表尚未建立,暂时不能导出。');
$filters = $this->storeViolationFilters($request);
$columns = (array) config('store_violations.columns', []);
$rows = $repository->exportRows($platform, $filters);
$label = (string) ($platforms[$platform]['label'] ?? $platform);
return response()->streamDownload(function () use ($rows, $columns): void {
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('店铺违规信息');
$headers = array_map(static fn (array $column): string => (string) $column['label'], $columns);
$sheet->fromArray($headers, null, 'A1');
$lastColumn = Coordinate::stringFromColumnIndex(max(count($headers), 1));
$sheet->getStyle("A1:{$lastColumn}1")->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension(1)->setRowHeight(24);
$rowNumber = 2;
foreach ($rows as $row) {
$values = [];
foreach ($columns as $column) {
$key = (string) $column['key'];
$value = $row->{$key} ?? null;
if ($key === 'points_deducted' && $value !== null) {
$value = (float) $value;
}
$values[] = $value === null ? '' : $value;
}
$sheet->fromArray($values, null, 'A' . $rowNumber);
$rowNumber++;
}
foreach (['A' => 30, 'B' => 36, 'C' => 14, 'D' => 24, 'E' => 14, 'F' => 10, 'G' => 16, 'H' => 14, 'I' => 16, 'J' => 12] as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width);
}
$sheet->freezePane('A2');
(new Xlsx($spreadsheet))->save('php://output');
}, '店铺违规信息_' . $label . '_' . now()->format('Ymd_His') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
/** 店铺违规信息预警:导入模板(统一表头 + 填写说明)。 */
public function storeViolationTemplate(): StreamedResponse
{
$columns = (array) config('store_violations.columns', []);
$handling = implode('、', (array) config('store_violations.handling_statuses', []));
return response()->streamDownload(function () use ($columns, $handling): void {
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('店铺违规信息');
$headers = array_map(static fn (array $column): string => (string) $column['label'], $columns);
$sheet->fromArray($headers, null, 'A1');
$sheet->fromArray(
['示例·某某旗舰店', '延迟发货/缺货无货', '发货履约', 'JD202610040001', '2026-10-04', 2, '待处理', '', '2026-10-11', '未处理'],
null,
'A2',
);
$lastColumn = Coordinate::stringFromColumnIndex(max(count($headers), 1));
$sheet->getStyle("A1:{$lastColumn}1")->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension(1)->setRowHeight(24);
foreach (['A' => 30, 'B' => 36, 'C' => 14, 'D' => 24, 'E' => 14, 'F' => 10, 'G' => 16, 'H' => 14, 'I' => 16, 'J' => 12] as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width);
}
$sheet->freezePane('A2');
$notes = $spreadsheet->createSheet();
$notes->setTitle('填写说明');
$notes->fromArray([
['字段', '说明'],
['店铺名', '必填。'],
['违规单号', '必填。同一平台内唯一;已存在的单号会按文件内容更新(导入预览会逐行提示)。'],
['违规类型', '选填。平台显示的违规内容/类型,例如“延迟发货/缺货无货”。'],
['违规分类', '选填。留空时按违规类型关键词自动归类。'],
['违规日期 / 处罚日期 / 申诉截止日期', '选填。格式 YYYY-MM-DD,例如 2026-10-04。'],
['扣分', '选填。数字,例如 2 或 0.5。'],
['状态', '选填。平台状态,例如“待处理”“超期未申诉”。'],
['处理状态', '选填。可选值:' . $handling . ';留空按“未处理”写入。'],
['平台', '无需填写。导入时按当前页签决定平台。'],
['提示', '请保留第一行表头;示例行可直接替换为实际数据。'],
], null, 'A1');
$notes->getStyle('A1:B1')->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
]);
$notes->getColumnDimension('A')->setWidth(34);
$notes->getColumnDimension('B')->setWidth(72);
$notes->getStyle('B1:B11')->getAlignment()->setWrapText(true);
(new Xlsx($spreadsheet))->save('php://output');
}, '店铺违规信息导入模板_' . now()->format('Ymd') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
/** 店铺违规信息预警:导入预览(先校验,不写库)。 */
public function previewStoreViolationImport(Request $request, StoreViolationWorkbookImporter $importer)
{
$maxKilobytes = (int) config('store_violations.import.max_kilobytes', 10240);
$request->validate([
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:' . $maxKilobytes],
'platform' => ['required', 'string'],
]);
$platform = (string) $request->input('platform');
abort_unless(array_key_exists($platform, (array) config('store_violations.platforms', [])), 422);
try {
$preview = $importer->preview($request->file('import'), $platform);
} catch (\Illuminate\Validation\ValidationException $exception) {
return redirect()
->route('business.pages.show', [
'section' => 'financial-risk-control',
'page' => 'store-violation-alert',
'platform' => $platform,
])
->withErrors($exception->errors())
->withInput();
}
$token = Str::random(40);
session()->put('store_violation_previews.' . $token, $preview);
return redirect()
->route('business.pages.show', [
'section' => 'financial-risk-control',
'page' => 'store-violation-alert',
'platform' => $platform,
])
->with('store_violation_preview', $preview + ['token' => $token]);
}
/** 店铺违规信息预警:确认导入。 */
public function commitStoreViolationImport(Request $request, StoreViolationWorkbookImporter $importer)
{
$token = (string) $request->input('preview_token');
$preview = session()->pull('store_violation_previews.' . $token);
abort_if($token === '' || ! is_array($preview), 422, '导入预览已过期,请重新上传文件。');
$result = $importer->commit($preview);
return redirect()
->route('business.pages.show', [
'section' => 'financial-risk-control',
'page' => 'store-violation-alert',
'platform' => (string) ($preview['platform'] ?? array_key_first((array) config('store_violations.platforms', []))),
])
->with('success', "导入完成:新增 {$result['created']} 条,更新 {$result['updated']} 条。");
}
/**
* 账单流水数据 › 种菜明细表。
*/
protected function contentForPlantingDetails(): array
{
$request = request();
$filters = $this->plantingDetailFilters($request);
$query = $this->plantingDetailQuery($filters);
$summaryRow = (clone $query)->selectRaw(
'COUNT(*) AS rows_count, COALESCE(SUM(pieces), 0) AS pieces_total, '
. 'COALESCE(SUM(finance_selling_price), 0) AS selling_price_total, '
. 'COALESCE(SUM(finance_payment_amount), 0) AS payment_amount_total, '
. 'COALESCE(SUM(deduction), 0) AS deduction_total, '
. 'COALESCE(SUM(jingxi_price_diff), 0) AS jingxi_diff_total'
)->first();
$limit = min(max((int) setting('default.list_limit', 25), 10), 100);
$records = (clone $query)
->orderByDesc('bill_date')
->orderByDesc('id')
->paginate($limit)
->withQueryString();
return [
'view' => 'business.pages.billing-transactions.planting-details',
'records' => $records,
'filters' => $filters,
'summary' => [
'rows' => (int) ($summaryRow->rows_count ?? 0),
'pieces' => (int) ($summaryRow->pieces_total ?? 0),
'selling_price' => (float) ($summaryRow->selling_price_total ?? 0),
'payment_amount' => (float) ($summaryRow->payment_amount_total ?? 0),
'deduction' => (float) ($summaryRow->deduction_total ?? 0),
'jingxi_diff' => (float) ($summaryRow->jingxi_diff_total ?? 0),
],
'filterOptions' => [
'stores' => \App\Models\Business\PlantingDetail::query()->where('company_id', company_id())
->whereNotNull('store')->distinct()->orderBy('store')->limit(500)->pluck('store')->all(),
'owners' => \App\Models\Business\PlantingDetail::query()->where('company_id', company_id())
->whereNotNull('owner')->distinct()->orderBy('owner')->limit(500)->pluck('owner')->all(),
'types' => \App\Models\Business\PlantingDetail::query()->where('company_id', company_id())
->whereNotNull('type')->distinct()->orderBy('type')->limit(200)->pluck('type')->all(),
'tags' => \App\Models\Business\PlantingDetail::query()->where('company_id', company_id())
->whereNotNull('tag')->distinct()->orderBy('tag')->limit(200)->pluck('tag')->all(),
],
];
}
/** @return array<string,string> 种菜明细表筛选条件 */
private function plantingDetailFilters(Request $request): array
{
return [
'date_from' => $this->dateFilter($request->query('date_from')),
'date_to' => $this->dateFilter($request->query('date_to')),
'store' => mb_substr(trim((string) $request->query('store')), 0, 191),
'owner' => mb_substr(trim((string) $request->query('owner')), 0, 191),
'type' => mb_substr(trim((string) $request->query('type')), 0, 191),
'tag' => mb_substr(trim((string) $request->query('tag')), 0, 191),
'keyword' => mb_substr(trim((string) $request->query('keyword')), 0, 191),
];
}
/** @param array<string,string> $filters */
private function plantingDetailQuery(array $filters): \Illuminate\Database\Eloquent\Builder
{
$query = \App\Models\Business\PlantingDetail::query()->where('company_id', company_id());
if ($filters['date_from'] !== '') {
$query->where('bill_date', '>=', $filters['date_from']);
}
if ($filters['date_to'] !== '') {
$query->where('bill_date', '<=', $filters['date_to']);
}
foreach (['store', 'owner', 'type', 'tag'] as $field) {
if ($filters[$field] !== '') {
$query->where($field, $filters[$field]);
}
}
if ($filters['keyword'] !== '') {
$like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['keyword']) . '%';
$query->where(function ($sub) use ($like): void {
$sub->where('order_no', 'like', $like)
->orWhere('remark', 'like', $like)
->orWhere('account', 'like', $like)
->orWhere('external_id', 'like', $like)
->orWhere('creator', 'like', $like);
});
}
return $query;
}
/** 账单流水数据 › 种菜明细表导出(按当前筛选,全量) */
public function exportPlantingDetails(Request $request): StreamedResponse
{
$query = $this->plantingDetailQuery($this->plantingDetailFilters($request));
return response()->streamDownload(function () use ($query): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建种菜明细导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, [
'日期', '订单编号', '财务售价', '财务运费', '财务佣金', '京喜差价', '扣款',
'财务空包费', '财务打款金额', '笔数', '备注', '正确店铺', '负责人', '账户',
'类型', '创建人', '标签', '所属利润中心', '负责平台', 'ID',
]);
$query->orderBy('id')->chunkById(2000, function ($rows) use ($csv): void {
foreach ($rows as $row) {
fputcsv($csv, [
$row->bill_date?->toDateString(),
$row->order_no,
$row->finance_selling_price,
$row->finance_freight,
$row->finance_commission,
$row->jingxi_price_diff,
$row->deduction,
$row->finance_empty_package_fee,
$row->finance_payment_amount,
$row->pieces,
$row->remark,
$row->store,
$row->owner,
$row->account,
$row->type,
$row->creator,
$row->tag,
$row->profit_center,
$row->responsible_platform,
$row->external_id,
]);
}
});
fclose($csv);
}, '种菜明细表_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/**
* 财务规则 · 科目映射表
*
* 表里存的是「科目对 + 方向基准」,不存最终借贷方向;记账时按账单实际收支代入。
* 页面支持导入追加、单条修改、新增、删除,并实时给出账套科目校验结果。
*/
protected function contentForAccountMapping(): array
{
$store = app(AccountMappingStore::class);
$validator = app(AccountMappingValidator::class);
$rows = [];
$summary = [
'total' => 0, 'error_rows' => 0, 'warning_rows' => 0, 'ok_rows' => 0,
'error_count' => 0, 'warning_count' => 0, 'tip_count' => 0,
];
$options = ['platforms' => [], 'categories' => [], 'basis' => [], 'invoices' => []];
$error = null;
try {
$rules = $store->all();
$validated = $validator->validate($rules);
foreach ($rules as $rule) {
$rows[] = $rule + ['issues' => $validated['issues'][(int) $rule['id']] ?? []];
}
$summary = $validated['summary'];
$options = $store->options();
} catch (Throwable $exception) {
$error = '映射表存储暂时读不出来,请稍后重试。';
}
return [
'view' => 'business.pages.financial-rules.account-mapping',
'amRows' => $rows,
'amSummary' => $summary,
'amOptions' => $options,
'amEndpoint' => $this->accountMappingEndpoint(),
'amError' => $error,
];
}
/** 维护接口前缀(去掉末尾的 /rules) */
protected function accountMappingEndpoint(): string
{
try {
$url = route('business.financial-rules.account-mapping.actions.rules');
} catch (Throwable) {
$url = url((int) (company_id() ?: 1) . '/workspace/financial-rules/account-mapping/actions/rules');
}
return (string) preg_replace('#/rules$#', '', $url);
}
protected function contentForCourierPayableSummary(string $page): array
{
$store = app(CourierPayableSummaryStore::class);
$snapshot = null;
$error = null;
try {
$snapshot = $store->snapshot((int) (company_id() ?: 1), $page);
} catch (RuntimeException) {
$error = '汇总数据暂时无法读取,请重新导入后再试。';
}
$headers = $snapshot['headers'] ?? [];
$allRows = collect($snapshot['rows'] ?? []);
$filter = app(CourierPayableSummaryFilter::class);
$filters = $filter->fromRequest(request());
$filteredRows = $filter->apply($allRows, $headers, $filters);
$perPage = 50;
$lastPage = max((int) ceil($filteredRows->count() / $perPage), 1);
$currentPage = min(max((int) request()->query('page', 1), 1), $lastPage);
return [
'view' => 'business.pages.courier-payables.summary-table',
'courierSummaryPage' => $page,
'courierSummaryTitle' => $store->title($page),
'courierSummaryHeaders' => $headers,
'courierSummaryRows' => new LengthAwarePaginator(
$filteredRows->forPage($currentPage, $perPage)->values(),
$filteredRows->count(),
$perPage,
$currentPage,
['path' => request()->url(), 'query' => array_filter(
$filters,
static fn (string $value): bool => $value !== '',
)],
),
'courierSummaryFilters' => $filters,
'courierSummaryHasFilters' => $filter->hasFilters($filters),
'courierSummarySourceTotal' => $allRows->count(),
'courierSummarySourceName' => $snapshot['source_name'] ?? null,
'courierSummaryImportedAt' => $snapshot['imported_at'] ?? null,
'courierSummaryError' => $error,
];
}
/**
* Consolidated, non-destructive view of the withdrawal configuration kept
* with each store receiving account. Updates remain in the source page so
* a withdrawal action cannot delete an entire store-account record.
*/
protected function contentForWithdrawalManagement(): array
{
$report = $this->withdrawalManagementReport->results(request());
$withdrawalRows = $report['rows'];
$page = max((int) request()->query('page', 1), 1);
$perPage = 50;
$withdrawalRows = new LengthAwarePaginator(
$withdrawalRows->forPage($page, $perPage)->values(),
$withdrawalRows->count(),
$perPage,
$page,
['path' => request()->url(), 'query' => request()->except('page')],
);
return [
'view' => 'business.pages.fund-management.withdrawal-management',
'withdrawalRows' => $withdrawalRows,
'withdrawalFilters' => $report['filters'],
'withdrawalPlatforms' => $report['platforms'],
'withdrawalPaths' => $report['paths'],
'withdrawalStoreEntities' => $report['storeEntities'],
'withdrawalBalances' => $report['balances'],
'withdrawalReserveStandards' => $report['reserveStandards'],
'withdrawalAmounts' => $report['withdrawalAmounts'],
'withdrawalCompliance' => $report['compliance'],
'withdrawalBalanceSummary' => $report['summary'],
'withdrawalBalanceColumnLabel' => $report['balanceColumnLabel'],
'withdrawalBalanceDate' => $report['balanceDate'],
'withdrawalSort' => $report['sort'],
];
}
/**
* 快递附加费报价维护。
*
* 报价维护与后续附加费结算分开:本页只负责渠道、类型、结算方式、
* 计价标准和生效时间的维护展示,结算页将来根据这些规则读取业务镜像。
*/
protected function contentForShippingData(): array
{
$request = request();
$filters = $this->shippingDataFilters($request);
$mode = $request->query('mode') === 'detail' ? 'detail' : 'merged';
$page = max((int) $request->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => $request->url(),
'query' => $request->except('page'),
]);
$sync = ['code' => 'unavailable', 'last_succeeded_at' => null];
$error = null;
try {
$result = $this->shippingOrders->paginate($filters, $page, $limit, $mode);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => $request->url(), 'query' => $request->except('page')],
);
$sync = $this->shippingOrders->syncStatus();
} catch (Throwable $exception) {
report($exception);
$error = '发货数据暂时无法读取(可能尚未运行迁移或同步)。';
}
return [
'view' => 'business.pages.courier-payables.shipping-data',
'shippingRows' => $rows,
'shippingFilters' => $filters,
'shippingSync' => $sync,
'shippingError' => $error,
'shippingMode' => $mode,
'shippingExportUrl' => route('business.courier-payables.shipping-data.export'),
];
}
public function exportShippingData(Request $request): StreamedResponse
{
$filters = $this->shippingDataFilters($request);
$mode = $request->query('mode') === 'detail' ? 'detail' : 'merged';
$columns = $this->shippingDataColumns();
return response()->streamDownload(function () use ($filters, $columns, $mode): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建发货数据导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
foreach ($this->shippingOrders->exportRows($filters, 1000, $mode) as $row) {
fputcsv($csv, array_map(function (array $column) use ($row): mixed {
$value = data_get($row, $column['field']);
// 发货数据导出:标识列(快递单号/交易单号/商品ID/条形码)按原样文本导出,不加 ' 前缀
return match ($column['type']) {
'number' => $value === null || $value === '' ? '' : $value,
default => $this->csvText($value),
};
}, $columns));
}
fclose($csv);
}, '发货数据' . ($mode === 'detail' ? '明细' : '合并') . '_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
public function suggestShippingData(Request $request): \Illuminate\Http\JsonResponse
{
$allowed = ['supplier_name', 'shop_name', 'warehouse_name', 'express_company'];
$field = (string) $request->query('field', '');
if (! in_array($field, $allowed, true)) {
return response()->json(['items' => []]);
}
$keyword = mb_substr(trim((string) $request->query('q', '')), 0, 64);
$limit = min(max((int) $request->query('limit', 20), 1), 500);
try {
$items = $this->shippingOrders->distinctValues($field, $keyword, $limit);
} catch (Throwable $exception) {
report($exception);
$items = [];
}
return response()->json(['items' => array_values($items)]);
}
/** @return array<string,mixed> */
private function shippingDataFilters(Request $request): array
{
$text = static fn (string $key, int $length = 128): string => mb_substr(
trim((string) $request->query($key, '')),
0,
$length,
);
// 多选字段:接收 name[] 数组(也兼容单个字符串),去重、限长、限个数。
$multi = static function (string $key, int $length = 191, int $max = 50) use ($request): array {
$raw = $request->query($key, []);
if (is_string($raw)) {
$raw = $raw === '' ? [] : [$raw];
}
if (! is_array($raw)) {
$raw = [];
}
$out = [];
foreach ($raw as $value) {
$value = mb_substr(trim((string) $value), 0, $length);
if ($value !== '' && ! in_array($value, $out, true)) {
$out[] = $value;
}
if (count($out) >= $max) {
break;
}
}
return $out;
};
$date = static function (mixed $value): string {
$value = trim((string) $value);
return preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $value) === 1 ? $value : '';
};
return [
'express_no' => $text('express_no', 80),
'sourceno' => $text('sourceno', 80),
'barcode' => $text('barcode'),
'supplier_name' => $multi('supplier_name'),
'shop_name' => $multi('shop_name', 255),
'warehouse_name' => $multi('warehouse_name'),
'express_company' => $multi('express_company'),
'ship_from' => $date($request->query('ship_from', '')),
'ship_to' => $date($request->query('ship_to', '')),
];
}
/** @return list<array{field:string,label:string,type:string}> */
private function shippingDataColumns(): array
{
return [
['field' => 'express_no', 'label' => '快递单号', 'type' => 'identifier'],
['field' => 'trade_no', 'label' => '交易单号', 'type' => 'identifier'],
['field' => 'item_id', 'label' => '商品ID', 'type' => 'identifier'],
['field' => 'barcode', 'label' => '条形码', 'type' => 'identifier'],
['field' => 'product_name', 'label' => '商品名称', 'type' => 'text'],
['field' => 'qty', 'label' => '数量', 'type' => 'number'],
['field' => 'pkg_count', 'label' => '件数', 'type' => 'text'],
['field' => 'receiver_state', 'label' => '省', 'type' => 'text'],
['field' => 'buyer_nick', 'label' => '买家昵称', 'type' => 'text'],
['field' => 'receiver_name', 'label' => '收货姓名', 'type' => 'text'],
['field' => 'shop_name', 'label' => '店铺名称', 'type' => 'text'],
['field' => 'express_name1', 'label' => '快递名称1', 'type' => 'text'],
['field' => 'warehouse_name', 'label' => '仓库', 'type' => 'text'],
['field' => 'ship_time', 'label' => '发货时间', 'type' => 'text'],
['field' => 'freight', 'label' => '运费', 'type' => 'number'],
['field' => 'online_weight', 'label' => '线上重量', 'type' => 'number'],
['field' => 'actual_weight', 'label' => '实称重量', 'type' => 'number'],
['field' => 'length_cm', 'label' => '长(cm)', 'type' => 'number'],
['field' => 'width_cm', 'label' => '宽(cm)', 'type' => 'number'],
['field' => 'height_cm', 'label' => '高(cm)', 'type' => 'number'],
['field' => 'volume_cm3', 'label' => '体积(cm³)', 'type' => 'number'],
['field' => 'receiver_city', 'label' => '市', 'type' => 'text'],
['field' => 'receiver_district', 'label' => '区', 'type' => 'text'],
['field' => 'supplier_name', 'label' => '供应商', 'type' => 'text'],
['field' => 'receiver_address', 'label' => '收货详细地址', 'type' => 'text'],
['field' => 'receiver_mobile', 'label' => '手机号码', 'type' => 'identifier'],
['field' => 'receiver_phone', 'label' => '座机号码', 'type' => 'identifier'],
['field' => 'express_name2', 'label' => '快递名称2', 'type' => 'text'],
['field' => 'platform', 'label' => '平台', 'type' => 'text'],
['field' => 'buyer_memo', 'label' => '买家留言', 'type' => 'text'],
['field' => 'seller_memo', 'label' => '卖家备注', 'type' => 'text'],
['field' => 'package_no', 'label' => '配货单号', 'type' => 'identifier'],
['field' => 'amount', 'label' => '金额', 'type' => 'number'],
['field' => 'product_code', 'label' => '货号', 'type' => 'identifier'],
['field' => 'download_time', 'label' => '下载时间', 'type' => 'text'],
['field' => 'pay_time', 'label' => '付款时间', 'type' => 'text'],
['field' => 'freight_rate', 'label' => '费率', 'type' => 'number'],
['field' => 'freight_formula', 'label' => '公式', 'type' => 'text'],
['field' => 'weight_kg', 'label' => '重量(kg)', 'type' => 'number'],
['field' => 'settle_weight_kg', 'label' => '结算重量(kg)', 'type' => 'number'],
['field' => 'settle_volume_cm3', 'label' => '结算体积(cm³)', 'type' => 'number'],
['field' => 'recon_weight_kg', 'label' => '对账重量(kg)', 'type' => 'number'],
['field' => 'recon_volume_cm3', 'label' => '对账体积(cm³)', 'type' => 'number'],
['field' => 'finished_barcode', 'label' => '成品条码', 'type' => 'identifier'],
['field' => 'finished_name', 'label' => '成品名称', 'type' => 'text'],
['field' => 'finished_supplier', 'label' => '成品供应商', 'type' => 'text'],
['field' => 'settle_freight', 'label' => '结算运费', 'type' => 'number'],
['field' => 'front_no', 'label' => '换单前单号', 'type' => 'identifier'],
];
}
protected function contentForCourierSurcharge(): array
{
$clean = static fn (mixed $value, int $length = 60): string => mb_substr(trim((string) $value), 0, $length);
$filters = [
'keyword' => $clean(request('keyword', ''), 40),
'courier' => $clean(request('courier', ''), 40),
'type' => $clean(request('type', ''), 40),
'mode' => $clean(request('mode', ''), 40),
'scope' => $clean(request('scope', ''), 20),
'reconciler' => $clean(request('reconciler', ''), 40),
'supplier' => $clean(request('supplier', ''), 191),
'status' => $clean(request('status', ''), 20),
];
$data = $this->courierSurchargeQuotes->pageData($filters);
$auditReady = $this->courierSurchargeAudits->ready();
return [
'view' => 'business.pages.courier-payables.courier-surcharge',
'courierSurchargeRows' => $data['rows'],
'courierSurchargeFilters' => $filters,
'courierSurchargeSummary' => $data['summary'],
'courierSurchargeOptions' => $data['options'],
'courierSurchargeReady' => $data['ready'] && $auditReady,
'courierSurchargeDataReady' => $data['ready'],
'courierSurchargeAuditReady' => $auditReady,
'courierSurchargeTypeUsage' => $this->courierSurchargeTypes->usageCounts($data['options']['type_records'] ?? collect()),
'courierSurchargeAuditLogs' => $this->courierSurchargeAudits->recent(),
];
}
private function freightReconciliationFilters(): array
{
$request = request();
$date = static function (mixed $value): string {
$value = trim((string) $value);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : '';
};
return [
'express_no' => mb_substr(trim((string) $request->query('express_no', '')), 0, 80),
'express_name' => mb_substr(trim((string) $request->query('express_name', '')), 0, 80),
'month' => mb_substr(trim((string) $request->query('month', '')), 0, 20),
'status' => mb_substr(trim((string) $request->query('status', '')), 0, 40),
'modified_from' => $date($request->query('modified_from', '')),
'modified_to' => $date($request->query('modified_to', '')),
];
}
protected function contentForFreightReconciliation(): array
{
$request = request();
$filters = $this->freightReconciliationFilters();
$page = max((int) $request->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new Paginator([], $limit, $page, [
'path' => $request->url(),
'query' => $request->except('page'),
]);
$state = null;
$sync = ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
$summary = ['count' => null, 'freight' => null];
$error = null;
$pending = false;
try {
if (! $this->courierFreightReconciliations->available()) {
$error = '共享业务镜像尚未建立,运费对账暂时无法读取。';
} elseif (! $this->courierFreightReconciliations->ready()) {
$state = $this->courierFreightReconciliations->state();
$sync = $this->courierFreightReconciliations->syncStatus($state);
$pending = true;
$error = '运费对账镜像正在完成首次同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierFreightReconciliations->paginate($filters, $page, $limit);
$rows = new Paginator(
Collection::make($result['data'] ?? []),
$limit,
$page,
['path' => $request->url(), 'query' => $request->except('page')],
);
$rows->hasMorePagesWhen((bool) ($result['has_more'] ?? false));
$state = $result['state'] ?? null;
$sync = $result['sync'] ?? $sync;
$summary = $result['summary'] ?? $summary;
}
} catch (Throwable $exception) {
report($exception);
$error = '运费对账数据暂时无法读取,请稍后重试。';
}
return [
'view' => 'business.pages.courier-payables.freight-reconciliation',
'freightReconciliations' => $rows,
'freightReconciliationFilters' => $filters,
'freightReconciliationSummary' => $summary,
'freightReconciliationState' => $state,
'freightReconciliationSync' => $sync,
'freightReconciliationError' => $error,
'freightReconciliationPending' => $pending,
'freightReconciliationSummaryUrl' => $error === null ? $request->fullUrlWithQuery(['courier_summary' => 1, 'page' => null]) : null,
];
}
protected function contentForThirdPartyWarehouseStorage(): array
{
$request = request();
$filters = $this->thirdPartyWarehouseStorageFilters($request);
$page = max((int) $request->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => $request->url(),
'query' => $request->except('page'),
]);
$state = null;
$sync = ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
$summary = [];
$error = null;
$pending = false;
try {
if (! $this->thirdPartyWarehouseStorage->available()) {
$error = '共享业务镜像尚未建立,三方仓进销存暂时无法读取。';
} elseif (! $this->thirdPartyWarehouseStorage->ready()) {
$state = $this->thirdPartyWarehouseStorage->state();
$sync = $this->thirdPartyWarehouseStorage->syncStatus($state);
$pending = true;
$error = ($sync['code'] ?? null) === 'dependency_pending'
? '普通商品资料镜像正在执行首次全量同步,完成前不会展示口径不完整的商品属性。'
: '三方仓进销存正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->thirdPartyWarehouseStorage->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
[
'path' => $request->url(),
'query' => $request->except('page'),
],
);
$state = $result['state'] ?? null;
$sync = $result['sync'] ?? $sync;
$summary = $result['summary'] ?? [];
}
} catch (Throwable $exception) {
report($exception);
$error = '三方仓进销存暂时无法读取,请稍后重试。';
}
return [
'view' => 'business.pages.courier-payables.third-party-warehouse-storage',
'thirdPartyWarehouseRows' => $rows,
'thirdPartyWarehouseFilters' => $filters,
'thirdPartyWarehouseSummary' => $summary,
'thirdPartyWarehouseState' => $state,
'thirdPartyWarehouseSync' => $sync,
'thirdPartyWarehouseError' => $error,
'thirdPartyWarehousePending' => $pending,
'thirdPartyWarehouseExportUrl' => route('business.courier-payables.third-party-warehouse-storage.export'),
];
}
public function exportThirdPartyWarehouseStorage(Request $request): StreamedResponse
{
abort_unless(
$this->thirdPartyWarehouseStorage->available() && $this->thirdPartyWarehouseStorage->ready(),
503,
);
$filters = $this->thirdPartyWarehouseStorageFilters($request);
$columns = $this->filterExportColumns(
$this->thirdPartyWarehouseStorageExportColumns(),
$request->query('fields'),
);
$period = str_replace('-', '', $filters['period']);
return response()->streamDownload(function () use ($filters, $columns): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建三方仓进销存导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
foreach ($this->thirdPartyWarehouseStorage->exportRows($filters) as $row) {
fputcsv($csv, array_map(function (array $column) use ($row): mixed {
$value = data_get($row, $column['field']);
return match ($column['type'] ?? 'text') {
'number', 'money' => $value === null || $value === '' ? '' : $value,
'code' => $this->csvIdentifier($value),
default => $this->csvText($value),
};
}, $columns));
}
fclose($csv);
}, '三方仓进销存_' . $period . '_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
public function exportInventoryReport(Request $request): Response
{
abort_unless(
$this->thirdPartyWarehouseStorage->available()
&& $this->thirdPartyWarehouseStorage->allWarehousesReady(),
503,
);
$filters = $this->inventoryReportFilters($request);
$productInventory = collect(config('business_navigation'))->firstWhere('slug', 'product-inventory');
$inventoryPage = collect($productInventory['pages'] ?? [])->firstWhere('slug', 'inventory-report');
$availableColumns = collect($inventoryPage['directory']['fields'] ?? [])
->reject(static fn (array $column): bool => in_array($column['kind'] ?? '', ['secret', 'password'], true))
->keyBy('name');
$requestedColumnsParam = $request->query('columns', '');
abort_unless(is_string($requestedColumnsParam), 422);
$requestedColumns = trim($requestedColumnsParam);
$columns = $availableColumns->values()->all();
if ($requestedColumns !== '') {
$names = explode(',', $requestedColumns);
abort_if(count($names) > $availableColumns->count() || count($names) !== count(array_unique($names)), 422);
$columns = [];
foreach ($names as $name) {
abort_unless($availableColumns->has($name), 422);
$columns[] = $availableColumns->get($name);
}
}
$scope = $request->query('scope', 'all');
abort_unless(in_array($scope, ['all', 'page'], true), 422);
$page = max(1, $request->integer('page', 1));
$perPage = min(max((int) setting('default.list_limit', 25), 1), 100);
if ($scope === 'all' && should_queue()) {
$fileName = Str::filename('进销存报表') . '-' . now()->format('Ymd_His') . '-' . Str::random(8) . '.csv';
dispatch(new InventoryReportExport(user(), $filters, $columns, $fileName, (int) (company_id() ?: 1)))->onQueue('exports');
flash('进销存报表已提交后台生成,完成后可从通知中心下载。')->success();
return back();
}
if ($scope === 'all') {
// The local environment intentionally uses the sync queue and has
// no worker process. Defer the same export job until the response
// has been sent so a large CSV never depends on a live browser
// download connection. The job stores the completed file and
// raises the existing notification when it is ready.
$fileName = Str::filename('进销存报表') . '-' . now()->format('Ymd_His') . '-' . Str::random(8) . '.csv';
dispatch(new InventoryReportExport(user(), $filters, $columns, $fileName, (int) (company_id() ?: 1)))->afterResponse();
flash('进销存报表已提交后台生成,完成后可从通知中心下载。')->success();
return back();
}
// Build the complete CSV before opening the HTTP response. Streaming
// rows directly from the database keeps the browser request open for
// the whole aggregation and makes a slow client look like a failed
// export. A disk-backed file keeps memory bounded and lets the web
// server send a framed response with a known length.
set_time_limit(0);
$filePath = tempnam(sys_get_temp_dir(), 'inventory-report-');
if ($filePath === false) {
throw new RuntimeException('无法创建进销存报表导出临时文件。');
}
try {
$csv = fopen($filePath, 'wb');
if ($csv === false) {
throw new RuntimeException('无法写入进销存报表导出临时文件。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
$rows = $scope === 'page'
? $this->thirdPartyWarehouseStorage->paginateMonthlyInventoryReport($filters, $page, $perPage, false)['data']
: $this->thirdPartyWarehouseStorage->exportMonthlyInventoryReportRows($filters);
$shortNames = app(InventoryWarehouseShortNames::class);
$names = $shortNames->forCompany((int) (company_id() ?: 1));
$written = 0;
foreach ($rows as $row) {
$row = $shortNames->fill($row, $names);
fputcsv($csv, array_map(function (array $column) use ($row): mixed {
$value = data_get($row, $column['name']);
return $column['name'] === 'product_code'
? $this->csvIdentifier($value)
: (($column['kind'] ?? null) === 'integer'
? ($value === null || $value === '' ? '' : $value)
: $this->csvText($value));
}, $columns));
$written++;
if ($written % 5000 === 0) {
fflush($csv);
gc_collect_cycles();
}
}
fclose($csv);
clearstatcache(true, $filePath);
$fileSize = filesize($filePath);
if ($fileSize === false) {
throw new RuntimeException('无法读取进销存报表导出文件大小。');
}
} catch (Throwable $exception) {
if (isset($csv) && is_resource($csv)) {
fclose($csv);
}
@unlink($filePath);
throw $exception;
}
$fileName = '进销存报表_' . str_replace('-', '', $filters['month']) . '_' . now()->format('Ymd_His') . '.csv';
return response()->streamDownload(function () use ($filePath): void {
try {
set_time_limit(0);
readfile($filePath);
} finally {
@unlink($filePath);
}
}, $fileName, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
'X-Accel-Buffering' => 'no',
'Content-Length' => (string) $fileSize,
]);
}
/** @return array<string,string> */
private function thirdPartyWarehouseStorageFilters(Request $request): array
{
$dimension = in_array((string) $request->query('dimension'), ['month', 'day'], true)
? (string) $request->query('dimension')
: 'month';
$period = trim((string) $request->query(
'period',
$dimension === 'day' ? $request->query('date', '') : $request->query('month', ''),
));
if ($dimension === 'day') {
if ($this->dateFilter($period) === '') {
$period = now()->subDay()->toDateString();
}
if ($period < ThirdPartyWarehouseStorageMirrorRepository::START_DATE) {
$period = ThirdPartyWarehouseStorageMirrorRepository::START_DATE;
}
} elseif (preg_match('/^(\d{4})-(\d{2})$/', $period, $matches) !== 1
|| ! checkdate((int) $matches[2], 1, (int) $matches[1])) {
$period = now()->startOfMonth()->subMonth()->format('Y-m');
}
if ($dimension === 'month'
&& $period < substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7)) {
$period = substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7);
}
$clean = static fn (mixed $value, int $length): string => mb_substr(trim((string) $value), 0, $length);
return [
'dimension' => $dimension,
'period' => $period,
'sku_code' => $clean($request->query('sku_code', ''), 80),
'product_name' => $clean($request->query('product_name', ''), 120),
'supplier' => $clean($request->query('supplier', ''), 120),
'category' => $clean($request->query('category', ''), 120),
];
}
/** @return array<int,array{label:string,field:string,type?:string}> */
private function thirdPartyWarehouseStorageExportColumns(): array
{
return [
['field' => 'owner', 'label' => '货主'],
['field' => 'date', 'label' => '日期'],
['field' => 'sku_code', 'label' => '商品编码', 'type' => 'code'],
['field' => 'image_url', 'label' => '图片'],
['field' => 'product_name', 'label' => '商品名称'],
['field' => 'short_name', 'label' => '商品简称'],
['field' => 'supplier', 'label' => '供应商'],
['field' => 'category', 'label' => '分类'],
['field' => 'virtual_category', 'label' => '虚拟分类'],
['field' => 'supplier_style_no', 'label' => '供应商款号', 'type' => 'code'],
['field' => 'color_spec', 'label' => '颜色规格'],
['field' => 'brand', 'label' => '品牌'],
['field' => 'unit', 'label' => '单位'],
['field' => 'opening_qty', 'label' => '期初数量', 'type' => 'number'],
['field' => 'closing_qty', 'label' => '期末数量', 'type' => 'number'],
['field' => 'processing_in', 'label' => '加工进仓', 'type' => 'number'],
['field' => 'processing_out', 'label' => '加工出仓', 'type' => 'number'],
['field' => 'purchase_in', 'label' => '采购进仓', 'type' => 'number'],
['field' => 'purchase_return', 'label' => '采购退货', 'type' => 'number'],
['field' => 'other_in', 'label' => '其它进仓', 'type' => 'number'],
['field' => 'other_return', 'label' => '其它退货', 'type' => 'number'],
['field' => 'sales_out', 'label' => '销售出仓', 'type' => 'number'],
['field' => 'sales_return', 'label' => '销售退货', 'type' => 'number'],
['field' => 'stocktake', 'label' => '盘点', 'type' => 'number'],
['field' => 'transfer_out', 'label' => '调拨出', 'type' => 'number'],
['field' => 'transfer_in', 'label' => '调拨入', 'type' => 'number'],
['field' => 'opening_aux', 'label' => '期初', 'type' => 'number'],
['field' => 'other_out', 'label' => '其它出库', 'type' => 'number'],
];
}
protected function contentForDingPayableWorkflows(): array
{
$normalizeDate = static function (mixed $value): string {
$date = trim((string) $value);
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $matches) !== 1
|| ! checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1])) {
return '';
}
return $date;
};
$status = strtoupper(mb_substr(trim((string) request('status', '')), 0, 30));
$filters = [
'process_instance_id' => mb_substr(trim((string) request('process_instance_id', '')), 0, 100),
'created_from' => $normalizeDate(request('created_from', '')),
'finished_to' => $normalizeDate(request('finished_to', '')),
'status' => preg_match('/^[A-Z_]+$/', $status) === 1 ? $status : '',
];
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
try {
$result = $this->dingPayableWorkflows->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => request()->url(), 'query' => request()->except('page')],
);
return [
'view' => 'business.pages.courier-payables.payable-cloud-warehouse-vehicle-fee',
'dingPayableWorkflowRows' => $rows,
'dingPayableWorkflowFilters' => $filters,
'dingPayableWorkflowStatus' => $result['status'] ?? 'unknown',
'dingPayableWorkflowSyncedAt' => $result['last_synced_at'] ?? null,
'dingPayableWorkflowError' => null,
];
} catch (Throwable $exception) {
report($exception);
return [
'view' => 'business.pages.courier-payables.payable-cloud-warehouse-vehicle-fee',
'dingPayableWorkflowRows' => new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(), 'query' => request()->except('page'),
]),
'dingPayableWorkflowFilters' => $filters,
'dingPayableWorkflowStatus' => 'unavailable',
'dingPayableWorkflowSyncedAt' => null,
'dingPayableWorkflowError' => '钉钉应付流程镜像尚未完成同步,请稍后重试。',
];
}
}
protected function contentForOnlineDaifaPrice(): array
{
$filters = $this->onlineDaifaPriceFilters(request());
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
try {
$result = $this->onlineDaifaPrices->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => request()->url(), 'query' => request()->except('page')],
);
return [
'view' => 'business.pages.product-inventory.online-daifa-price',
'onlineDaifaRows' => $rows,
'onlineDaifaFilters' => $filters,
'onlineDaifaStatus' => $result['status'] ?? 'unknown',
'onlineDaifaSyncedAt' => $result['last_synced_at'] ?? null,
'onlineDaifaSummary' => $result['summary'] ?? [],
'onlineDaifaSources' => $result['source_statuses'] ?? [],
'onlineDaifaError' => null,
];
} catch (Throwable $exception) {
report($exception);
return [
'view' => 'business.pages.product-inventory.online-daifa-price',
'onlineDaifaRows' => new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(), 'query' => request()->except('page'),
]),
'onlineDaifaFilters' => $filters,
'onlineDaifaStatus' => 'unavailable',
'onlineDaifaSyncedAt' => null,
'onlineDaifaSummary' => [],
'onlineDaifaSources' => [],
'onlineDaifaError' => '线上代发价格镜像尚未完成同步,请稍后重试。',
];
}
}
public function exportOnlineDaifaPrice(Request $request): StreamedResponse
{
abort_unless($this->onlineDaifaPrices->ready(), 503);
$filters = $this->onlineDaifaPriceFilters($request);
return response()->streamDownload(function () use ($filters): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建线上代发价格导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, [
'审批编号', '标题', '状态', '申请人', '收款人全称', '标签', '商品ID', '商品名称(匹配)', '店铺名称',
'代发交易单号', '店铺交易单号', '发货数量', '订单实付金额', '代发订单金额', '差价',
'供应商名称', '商品编码', '创建时间',
]);
foreach ($this->onlineDaifaPrices->exportRows($filters) as $row) {
$workflow = (array) ($row['workflow'] ?? []);
$approvalNumber = trim((string) ($workflow['businessId'] ?? '')) ?: (string) ($row['process_instance_id'] ?? '');
$originator = trim((string) ($workflow['originatorUserName'] ?? ''))
?: trim((string) ($workflow['originatorUserId'] ?? ''));
$matchedProduct = (array) ($row['matched_product'] ?? []);
$productName = ($row['source_type'] ?? 'ding') === 'purchase'
? ''
: (($matchedProduct['matched'] ?? false)
? (string) ($matchedProduct['name'] ?? '')
: '(未匹配)');
fputcsv($csv, [
$this->csvIdentifier($approvalNumber),
$this->csvText($workflow['title'] ?? ''),
$this->csvText($row['status_label'] ?? ''),
$this->csvText($originator),
$this->csvText($row['payee_name'] ?? ''),
$this->csvText($row['tag'] ?? ''),
$this->csvIdentifier($row['product_id'] ?? ''),
$this->csvText($productName),
$this->csvText($row['shop_name'] ?? ''),
$this->csvIdentifier($row['daifa_trade_no'] ?? ''),
$this->csvIdentifier($row['shop_trade_no'] ?? ''),
$row['ship_qty'] ?? null,
$row['paid_amount'] ?? null,
$row['daifa_amount'] ?? null,
$row['price_diff'] ?? null,
$this->csvText($row['supplier_name'] ?? ''),
$this->csvIdentifier($row['material_no'] ?? ''),
$this->csvText($row['workflow_created_at'] ?? ''),
]);
}
fclose($csv);
}, '线上代发价格_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @return array<string,string> */
private function onlineDaifaPriceFilters(Request $request): array
{
$clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length);
$date = static function (mixed $value): string {
$value = trim((string) $value);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : '';
};
$status = strtoupper(mb_substr(trim((string) $request->query('status', '')), 0, 30));
return [
'keyword' => $clean($request->query('keyword', ''), 80),
'product_id' => $clean($request->query('product_id', ''), 64),
'shop_name' => $clean($request->query('shop_name', ''), 120),
'supplier_name' => $clean($request->query('supplier_name', ''), 120),
'material_no' => $clean($request->query('material_no', ''), 120),
'status' => preg_match('/^[A-Z_]+$/', $status) === 1 ? $status : '',
'created_from' => $date($request->query('created_from', '')),
'created_to' => $date($request->query('created_to', '')),
];
}
protected function contentForJingxiRefundData(): array
{
return [
'view' => 'business.pages.refunds.jingxi-refund-data',
];
}
protected function contentForNonJingxiRefundData(): array
{
$request = request();
$defaultTo = now()->toDateString();
$defaultFrom = now()->subDays(6)->toDateString();
$filters = $this->nonJingxiRefundFilters($request, $defaultFrom, $defaultTo);
$page = max((int) $request->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, 1, [
'path' => $request->url(),
'query' => $request->query(),
]);
$syncStatus = ['code' => 'unavailable', 'last_succeeded_at' => null];
$error = null;
$pending = false;
$indexPending = false;
try {
if (! $this->nonJingxiRefunds->available()) {
$error = '共享业务镜像尚未建立,非京喜退款数据暂时无法读取。';
} else {
$states = $this->nonJingxiRefunds->states();
$syncStatus = $this->nonJingxiRefunds->syncStatus($states);
if (! $this->nonJingxiRefunds->ready()) {
$pending = true;
} elseif (! $this->nonJingxiRefunds->queryReady()) {
$indexPending = true;
} else {
$result = $this->nonJingxiRefunds->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => $request->url(), 'query' => $request->query()],
);
}
}
} catch (Throwable $exception) {
report($exception);
$error = '非京喜退款数据暂时无法读取,请稍后重试。';
}
return [
'view' => 'business.pages.refunds.non-jingxi-refund-data',
'filters' => $filters,
'rows' => $rows,
'refundSyncStatus' => $syncStatus,
'refundError' => $error,
'refundPending' => $pending,
'refundIndexPending' => $indexPending,
];
}
public function exportNonJingxiRefunds(Request $request)
{
$filters = $this->nonJingxiRefundFilters($request);
$redirectParameters = array_filter([
'section' => 'refunds',
'page' => 'non-jingxi-refund-data',
...$filters,
], static fn (mixed $value): bool => $value !== '' && $value !== null);
try {
if (! $this->nonJingxiRefunds->available()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '共享业务镜像尚未建立,售后(退货退款)暂时不能导出。');
}
if (! $this->nonJingxiRefunds->ready()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '业务镜像首次同步尚未完成,售后(退货退款)暂时不能导出。');
}
if (! $this->nonJingxiRefunds->queryReady()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '页面查询索引尚未就绪,售后(退货退款)暂时不能导出。');
}
return $this->streamNonJingxiRefundCsv(
$this->nonJingxiRefunds->exportRows($filters),
$filters,
$redirectParameters,
$request->query('columns'),
);
} catch (Throwable $exception) {
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '售后(退货退款)导出暂时无法完成,请稍后重试。');
}
}
/** @return array{from:string,to:string,order_no:string,shop_name:string,sku_id:string} */
private function nonJingxiRefundFilters(
Request $request,
?string $defaultFrom = null,
?string $defaultTo = null,
): array {
$defaultTo ??= now()->toDateString();
$defaultFrom ??= now()->subDays(6)->toDateString();
$from = $this->dateFilter($request->query('from', $defaultFrom)) ?: $defaultFrom;
$to = $this->dateFilter($request->query('to', $defaultTo)) ?: $defaultTo;
if ($from > $to) {
[$from, $to] = [$to, $from];
}
return [
'from' => $from,
'to' => $to,
'order_no' => mb_substr(trim((string) $request->query('order_no', '')), 0, 100),
'shop_name' => mb_substr(trim((string) $request->query('shop_name', '')), 0, 191),
'sku_id' => mb_substr(trim((string) $request->query('sku_id', '')), 0, 100),
];
}
private function streamNonJingxiRefundCsv(iterable $rows, array $filters, array $redirectParameters, mixed $requestedFields = null)
{
$columns = [
['label' => '序号/主键ID', 'field' => 'Id', 'identifier' => true],
['label' => '退款日期', 'field' => 'refundDate'],
['label' => '售后单ID', 'field' => 'as_id', 'identifier' => true],
['label' => '外部售后单号', 'field' => 'outer_as_id', 'identifier' => true],
['label' => '订单ID', 'field' => 'o_id', 'identifier' => true],
['label' => '', 'field' => ''],
['label' => '子订单ID', 'field' => 'so_id', 'identifier' => true],
['label' => '类型', 'field' => 'type'],
['label' => '节点', 'field' => 'node'],
['label' => '是否自营', 'field' => 'isZC'],
['label' => '店铺名称', 'field' => 'shop_name'],
['label' => '平台', 'field' => 'platform'],
['label' => '责任部门', 'field' => 'responsibleDepartment'],
['label' => '退款总金额', 'field' => 'totalRefundMoney', 'number' => true],
['label' => '原始邮编', 'field' => 'originPostcode'],
['label' => '店铺商品ID', 'field' => 'shop_i_id', 'identifier' => true],
['label' => '店铺SKU编码', 'field' => 'shop_sku_id', 'identifier' => true],
['label' => '商品ID', 'field' => 'i_id', 'identifier' => true],
['label' => 'SKU编码', 'field' => 'sku_id', 'identifier' => true],
['label' => '买家实付金额', 'field' => 'buyer_paid_amount', 'number' => true],
['label' => '数量', 'field' => 'qty', 'number' => true],
['label' => '商品单位成本', 'field' => 'unitCostGoods', 'number' => true],
['label' => '明细退款金额', 'field' => 'detailRefundMoney', 'number' => true],
['label' => '退款类型', 'field' => 'refundType'],
['label' => '退款数量', 'field' => 'refundQty', 'number' => true],
['label' => '退款商品成本', 'field' => 'refundCostGoods', 'number' => true],
['label' => '退款回款金额', 'field' => 'RefundMoneyIncome', 'number' => true],
['label' => '售后金额', 'field' => 'AfSalesMoney', 'number' => true],
['label' => '创建时间', 'field' => 'createTime'],
['label' => '状态', 'field' => 'status'],
['label' => '店铺状态', 'field' => 'shop_status'],
['label' => '创建记录/创建时间', 'field' => 'created'],
['label' => '确认日期', 'field' => 'confirm_date'],
];
$columns = $this->filterExportColumns($columns, $requestedFields);
$csv = null;
try {
$csv = fopen('php://temp/maxmemory:5242880', 'w+b');
if ($csv === false) {
throw new \RuntimeException('无法创建售后退款导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
foreach ($rows as $row) {
fputcsv($csv, array_map(
fn (array $column): mixed => $column['field'] === ''
? ''
: (($column['number'] ?? false)
? data_get($row, $column['field'])
: (($column['identifier'] ?? false)
? $this->csvIdentifier(data_get($row, $column['field']))
: $this->csvText(data_get($row, $column['field'])))),
$columns,
));
}
rewind($csv);
} catch (Throwable $exception) {
if (is_resource($csv)) {
fclose($csv);
}
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '售后(退货退款)导出暂时无法完成,请稍后重试。');
}
return response()->streamDownload(function () use ($csv): void {
fpassthru($csv);
fclose($csv);
}, 'after_sales_refunds_' . $filters['from'] . '_' . $filters['to'] . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
protected function contentForSmallPaymentWorkOrders(): array
{
$request = request();
$filters = $this->smallPaymentWorkOrderFilters($request);
$page = max((int) $request->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, 1, [
'path' => $request->url(),
'query' => $request->query(),
]);
$syncStatus = ['code' => 'unavailable', 'last_succeeded_at' => null];
$pagePaymentAmount = '0.00';
$filteredPaymentAmount = '0.00';
$shopNames = [];
$paymentReasons = [];
$error = null;
$pending = false;
$indexPending = false;
try {
if (! $this->smallPaymentWorkOrders->available()) {
$error = '共享业务镜像尚未建立,小额打款工单暂时无法读取。';
} else {
$states = $this->smallPaymentWorkOrders->states();
$syncStatus = $this->smallPaymentWorkOrders->syncStatus($states);
if (! $this->smallPaymentWorkOrders->ready()) {
$pending = true;
} elseif (! $this->smallPaymentWorkOrders->queryReady()) {
$indexPending = true;
} else {
$result = $this->smallPaymentWorkOrders->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => $request->url(), 'query' => $request->query()],
);
$pagePaymentAmount = (string) ($result['page_payment_amount'] ?? '0.00');
$filteredPaymentAmount = (string) ($result['filtered_payment_amount'] ?? '0.00');
$filterOptions = $this->smallPaymentWorkOrders->filterOptions();
$shopNames = $filterOptions['shops'];
$paymentReasons = $filterOptions['payment_reasons'];
}
}
} catch (Throwable $exception) {
report($exception);
$error = '小额打款工单暂时无法读取,请稍后重试。';
}
return [
'view' => 'business.pages.refunds.small-payment-work-orders',
'filters' => $filters,
'rows' => $rows,
'smallPaymentSyncStatus' => $syncStatus,
'smallPaymentPageAmount' => $pagePaymentAmount,
'smallPaymentFilteredAmount' => $filteredPaymentAmount,
'smallPaymentShops' => $shopNames,
'smallPaymentReasons' => $paymentReasons,
'smallPaymentStatuses' => [
'待审核', '审核驳回', '待确认', '确认中', '已完成', '已作废',
'支付宝已打款', '银行卡已打款', '已打款',
],
'smallPaymentError' => $error,
'smallPaymentPending' => $pending,
'smallPaymentIndexPending' => $indexPending,
];
}
public function exportSmallPaymentWorkOrders(Request $request)
{
$filters = $this->smallPaymentWorkOrderFilters($request);
$redirectParameters = array_filter([
'section' => self::SMALL_PAYMENT_SECTION,
'page' => 'small-payment-work-orders',
...$filters,
], static fn (mixed $value): bool => $value !== '' && $value !== null);
try {
if (! $this->smallPaymentWorkOrders->available()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '共享业务镜像尚未建立,小额打款工单暂时不能导出。');
}
if (! $this->smallPaymentWorkOrders->ready()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '业务镜像首次同步尚未完成,小额打款工单暂时不能导出。');
}
if (! $this->smallPaymentWorkOrders->queryReady()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '页面查询索引尚未就绪,小额打款工单暂时不能导出。');
}
return $this->streamSmallPaymentWorkOrderCsv(
$this->smallPaymentWorkOrders->exportRows($filters),
$filters,
$redirectParameters,
$request->query('columns'),
);
} catch (Throwable $exception) {
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '小额打款工单导出暂时无法完成,请稍后重试。');
}
}
/**
* @return array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string}
*/
private function smallPaymentWorkOrderFilters(Request $request): array
{
$defaultTo = now()->format('Y-m-d') . 'T23:59:59';
$defaultFrom = now()->subDays(29)->format('Y-m-d') . 'T00:00:00';
$rawFrom = $request->query('from', $defaultFrom);
$rawTo = $request->query('to', $defaultTo);
$from = $this->smallPaymentDateTimeFilter($rawFrom, false) ?: $defaultFrom;
$to = $this->smallPaymentDateTimeFilter($rawTo, true) ?: $defaultTo;
if ($from > $to) {
$from = $this->smallPaymentDateTimeFilter($rawTo, false) ?: $defaultFrom;
$to = $this->smallPaymentDateTimeFilter($rawFrom, true) ?: $defaultTo;
}
$text = static fn (mixed $value, int $length = 100): string => mb_substr(trim((string) $value), 0, $length);
return [
'from' => $from,
'to' => $to,
'work_order_no' => $text($request->query('work_order_no', '')),
'shop_name' => $text($request->query('shop_name', ''), 191),
'transaction_no' => $text($request->query('transaction_no', '')),
'online_order_no' => $text($request->query('online_order_no', '')),
'status' => $text($request->query('status', ''), 60),
'payment_reason' => $text($request->query('payment_reason', ''), 191),
];
}
private function smallPaymentDateTimeFilter(mixed $value, bool $upperBound): string
{
$value = trim((string) $value);
if (preg_match('/^(\d{4}-\d{2}-\d{2})$/', $value, $dateMatch) === 1) {
if ($this->dateFilter($dateMatch[1]) === '') {
return '';
}
return $dateMatch[1] . ($upperBound ? 'T23:59:59' : 'T00:00:00');
}
if (preg_match(
'/^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/',
$value,
$matches,
) !== 1 || $this->dateFilter($matches[1]) === '') {
return '';
}
$hour = (int) $matches[2];
$minute = (int) $matches[3];
$second = isset($matches[4]) ? (int) $matches[4] : ($upperBound ? 59 : 0);
if ($hour > 23 || $minute > 59 || $second > 59) {
return '';
}
return sprintf('%sT%02d:%02d:%02d', $matches[1], $hour, $minute, $second);
}
private function streamSmallPaymentWorkOrderCsv(iterable $rows, array $filters, array $redirectParameters, mixed $requestedFields = null)
{
$columns = [
['label' => '工单编号', 'field' => 'work_order_no', 'identifier' => true],
['label' => '支付宝批次号', 'field' => 'alipay_batch_no', 'identifier' => true],
['label' => '工单状态', 'field' => 'work_order_status'],
['label' => '打款状态', 'field' => 'payment_status'],
['label' => '工单标题', 'field' => 'work_order_title'],
['label' => '工单内容', 'field' => 'work_order_content'],
['label' => '工单类型', 'field' => 'work_order_type'],
['label' => '优先级', 'field' => 'priority'],
['label' => '打款金额', 'field' => 'payment_amount', 'number' => true],
['label' => '催处理次数', 'field' => 'urging_count', 'number' => true],
['label' => '店铺名称', 'field' => 'shop_name'],
['label' => '线上订单号', 'field' => 'online_order_no', 'identifier' => true],
['label' => '买家账号', 'field' => 'buyer_account'],
['label' => '支付宝账号', 'field' => 'alipay_account'],
['label' => '支付宝姓名', 'field' => 'alipay_name'],
['label' => '商品编码', 'field' => 'product_code', 'identifier' => true],
['label' => 'SKU编码', 'field' => 'sku_code', 'identifier' => true],
['label' => '平台', 'field' => 'platform'],
['label' => '实际已付金额', 'field' => 'actual_paid_amount', 'number' => true],
['label' => '支付时间', 'field' => 'payment_time'],
['label' => '打款原因', 'field' => 'payment_reason'],
['label' => '创建人', 'field' => 'creator'],
['label' => '创建时间', 'field' => 'work_order_created_at'],
['label' => '完成时间', 'field' => 'completed_at'],
];
$columns = $this->filterExportColumns($columns, $requestedFields);
$csv = null;
try {
$csv = fopen('php://temp/maxmemory:5242880', 'w+b');
if ($csv === false) {
throw new \RuntimeException('无法创建小额打款工单导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
foreach ($rows as $row) {
fputcsv($csv, array_map(
fn (array $column): mixed => ($column['number'] ?? false)
? data_get($row, $column['field'])
: (($column['identifier'] ?? false)
? $this->csvIdentifier(data_get($row, $column['field']))
: $this->csvText(data_get($row, $column['field']))),
$columns,
));
}
rewind($csv);
} catch (Throwable $exception) {
if (is_resource($csv)) {
fclose($csv);
}
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '小额打款工单导出暂时无法完成,请稍后重试。');
}
$filenameFrom = str_replace(['T', ':'], ['_', '-'], $filters['from']);
$filenameTo = str_replace(['T', ':'], ['_', '-'], $filters['to']);
return response()->streamDownload(function () use ($csv): void {
fpassthru($csv);
fclose($csv);
}, 'small_payment_work_orders_' . $filenameFrom . '_' . $filenameTo . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
protected function contentForMarketingAccountBalance(?string $tab): array
{
$content = [
'view' => 'banking.marketing_account_balances.partials.embedded-table',
'platform' => $tab,
'balances' => collect(),
'error' => null,
'pending' => false,
];
if (! in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet'], true)) {
return $content;
}
$request = request();
$page = max(1, (int) $request->query('page', 1));
$pageSize = min(max((int) setting('default.list_limit', 25), 1), 100);
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$month = trim((string) $request->query('month', ''));
$month = preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : '';
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'month' => $month !== '' ? $month : null,
];
$isTaobao = $tab === 'taobao-alipay';
$isAlibaba = $tab === 'taobao-wechat';
$isJd = $tab === 'jd-wallet';
$repository = match ($tab) {
'taobao-alipay' => app(TmallTaobaoSpreadBillRepository::class),
'taobao-wechat' => app(AlibabaAccountBalanceRepository::class),
'jd-wallet' => app(JdPromotionAccountBalanceRepository::class),
default => app(PinduoduoSpreadBillRepository::class),
};
$scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list';
$cache = ($isAlibaba || $isJd) ? null : $this->upstreamCaches->forSource('jiansun');
$usingMirror = $repository->usesBusinessMirror();
try {
if ($isJd && ! $usingMirror) {
throw new ServiceUnavailableHttpException(null, '京东账户余额共享业务镜像尚未完成同步,请稍后重试。');
}
$resolver = fn () => $repository->paginateMonthlyBalances(
$filters['store_name'],
$filters['month'],
$page,
$pageSize,
);
$result = ($usingMirror || $isAlibaba || $isJd)
? $resolver()
: $cache->remember(
$scope,
['view' => 'monthly-balances', 'filters' => $filters, 'page' => $page, 'limit' => $pageSize],
$resolver,
);
} catch (UpstreamSnapshotPendingException $exception) {
$content['pending'] = true;
$result = ['rows' => [], 'total' => 0];
} catch (ServiceUnavailableHttpException $exception) {
$content['error'] = $exception->getMessage();
$result = ['rows' => [], 'total' => 0];
} catch (Throwable $exception) {
report($exception);
$platformName = $isTaobao ? '淘系' : ($isAlibaba ? '阿里巴巴' : ($isJd ? '京东' : '拼多多'));
$content['error'] = $platformName . '账户余额数据暂时无法读取,请稍后重试。';
$result = ['rows' => [], 'total' => 0];
}
try {
$resolver = fn () => $repository->storeOptions();
$storeOptions = ($usingMirror || $isAlibaba || $isJd)
? $resolver()
: $cache->remember(
$isTaobao ? 'taobao-alipay-bills.options' : 'pinduoduo-bills.options',
[],
$resolver,
);
} catch (Throwable $exception) {
report($exception);
$storeOptions = [];
}
$content['balances'] = new LengthAwarePaginator(
Collection::make($result['rows'] ?? []),
(int) ($result['total'] ?? 0),
$pageSize,
$page,
['path' => $request->url(), 'query' => $request->query()],
);
$content['filters'] = ['store_name' => $storeName, 'month' => $month];
$content['storeOptions'] = $storeOptions;
return $content;
}
public function export(Request $request, string $section, string $page, string $tab)
{
abort_unless(in_array($section, ['billing-transactions', self::ACCOUNT_BALANCE_SECTION], true), 404);
$sectionConfig = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', $section);
abort_if(empty($sectionConfig), 403, '当前账号没有该页面权限。');
$pageConfig = collect($sectionConfig['pages'] ?? [])->firstWhere('slug', $page);
abort_if(empty($pageConfig), 403, '当前账号没有该页面权限。');
abort_unless(collect($pageConfig['tabs'] ?? [])->pluck('slug')->contains($tab), 404);
if ($page === 'marketing-account-bills') {
abort_unless($section === 'billing-transactions', 404);
if (in_array($tab, self::PROMOTION_MARKETING_TABS, true)) {
return $this->exportPlatformPromotionAccounts($request, $section, $page, $tab);
}
return $this->exportMarketingAccountBills($request, $section, $page, $tab);
}
abort_unless($section === self::ACCOUNT_BALANCE_SECTION && $page === 'marketing-account-balance', 404);
abort_unless(in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet'], true), 404);
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$month = trim((string) $request->query('month', ''));
$month = preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : '';
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'month' => $month !== '' ? $month : null,
];
$isTaobao = $tab === 'taobao-alipay';
$isAlibaba = $tab === 'taobao-wechat';
$isJd = $tab === 'jd-wallet';
$repository = match ($tab) {
'taobao-alipay' => app(TmallTaobaoSpreadBillRepository::class),
'taobao-wechat' => app(AlibabaAccountBalanceRepository::class),
'jd-wallet' => app(JdPromotionAccountBalanceRepository::class),
default => app(PinduoduoSpreadBillRepository::class),
};
$scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list';
$redirectParameters = array_filter([
'section' => $section,
'page' => $page,
'tab' => $tab,
'store_name' => $storeName,
'month' => $month,
], static fn (mixed $value): bool => $value !== '');
try {
$usingMirror = $repository->usesBusinessMirror();
if ($isJd && ! $usingMirror) {
throw new ServiceUnavailableHttpException(null, '京东账户余额共享业务镜像尚未完成同步。');
}
$resolver = fn () => $repository->paginateMonthlyBalances($filters['store_name'], $filters['month'], 1, 5000);
$result = ($usingMirror || $isAlibaba || $isJd)
? $resolver()
: $this->upstreamCaches->forSource('jiansun')->remember(
$scope,
['view' => 'monthly-balances', 'filters' => $filters, 'page' => 1, 'limit' => 5000, 'export' => true],
$resolver,
);
} catch (ServiceUnavailableHttpException) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', $isJd
? '京东账户余额共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。'
: '首份共享快照正在准备,系统已登记刷新请求;请稍后刷新页面后再导出。');
} catch (Throwable $exception) {
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '余额数据导出暂时无法完成,请稍后重试。');
}
$rows = $result['rows'] ?? [];
$filePrefix = $isTaobao ? 'taobao' : ($isAlibaba ? 'alibaba' : ($isJd ? 'jd' : 'pinduoduo'));
return response()->streamDownload(function () use ($rows): void {
$csv = fopen('php://output', 'w');
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, [
'日期', '店铺名称', '期初余额', '本期收入', '本期支出', '期末余额',
'系统期末余额', '余额差异', '差异原因', '核对状态', '备注',
]);
foreach ($rows as $row) {
fputcsv($csv, [
$row['date'], $row['store_name'], $row['opening_balance'], $row['income_total'],
$row['expense_total'], $row['closing_balance'], $row['system_closing_balance'],
$row['balance_difference'], $row['difference_reason'], $row['verification_status'], $row['notes'],
]);
}
fclose($csv);
}, $filePrefix . '_account_balances_' . ($month !== '' ? $month : 'all') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
private function exportPlatformPromotionAccounts(
Request $request,
string $section = 'billing-transactions',
string $page = 'marketing-account-bills',
?string $tab = null,
)
{
$platform = PlatformPromotionAccountPageData::platformForTab($tab);
abort_if($platform === null, 404);
$month = trim((string) $request->query('month', ''));
$month = preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : null;
$filters = [
'month' => $month,
'shop_name' => mb_substr(trim((string) $request->query('shop_name', '')), 0, 100),
'account' => mb_substr(trim((string) $request->query('account', '')), 0, 100),
];
if ($platform === 'vip') {
$filters['advertiser_id'] = mb_substr(trim((string) $request->query('advertiser_id', '')), 0, 100);
}
try {
$repository = app(\Modules\EcommerceFinance\Repositories\PlatformPromotionAccountRepository::class);
$filters['month'] = $month ?: ($repository->latestMonth() ?: now()->format('Y-m'));
$data = $repository->dashboard($filters, 5000, $platform);
} catch (Throwable $exception) {
report($exception);
return redirect()->route('business.pages.show', array_filter([
'section' => $section,
'page' => $page,
'tab' => $tab,
], static fn (mixed $value): bool => $value !== null && $value !== ''))
->with('error', '推广账户数据导出暂时无法完成,请稍后重试。');
}
return response()->streamDownload(function () use ($data, $platform): void {
$csv = fopen('php://output', 'w');
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
if ($platform === 'vip') {
fputcsv($csv, [
'平台', '营销账户', '账户名称', '广告主 ID', '广告主名称',
'报表日期', '花费(元)', '曝光量', '更新时间',
]);
foreach ($data['vipMarketingDaily'] ?? [] as $row) {
fputcsv($csv, [
'唯品会推广流水',
$row['account'] ?? '',
$row['accountName'] ?? '',
$row['advertiserId'] ?? '',
$row['advertiserTitle'] ?? '',
$row['reportDate'] ?? '',
$row['costYuan'] ?? '',
$row['impressionCount'] ?? '',
$row['updatedAt'] ?? '',
]);
}
fclose($csv);
return;
}
if ($platform === 'jd') {
fputcsv($csv, [
'平台', '店铺', '账户', '日期', '记账时间', '收支类型', '交易类型',
'金额(元)', '余额(元)', '备注',
]);
foreach ($data['jd'] ?? [] as $row) {
fputcsv($csv, [
'京东京准通',
$row['shop_name'] ?? '',
$row['account'] ?? '',
$row['show_date'] ?? '',
$row['settle_datetime'] ?? '',
$row['in_out_type_name'] ?? '',
$row['trade_type_name'] ?? '',
$row['amount'] ?? '',
$row['balance_amount'] ?? '',
$row['remark'] ?? '',
]);
}
fclose($csv);
return;
}
fputcsv($csv, ['平台', '店铺/账户', '日期', '类型', '金额(元)', '余额(元)', '备注']);
$emit = static function (string $platform, array $rows, array $map) use ($csv): void {
foreach ($rows as $row) {
fputcsv($csv, [
$platform,
$map['name']($row),
$map['date']($row),
$map['type']($row),
$map['amount']($row),
$map['balance']($row),
$map['remark']($row),
]);
}
};
if ($platform === 'douyin') {
$emit('抖音千川', $data['dy'] ?? [], ['name' => fn ($row) => $row['shopName'] . ' / ' . $row['account'] . ' / ' . $row['qianchuanAavid'], 'date' => fn ($row) => $row['statDate'] ?? '', 'type' => fn () => '花费', 'amount' => fn ($row) => $row['cost'] ?? '', 'balance' => fn ($row) => $row['balance'] ?? '', 'remark' => fn ($row) => $row['remark'] ?? '']);
} elseif ($platform === 'dewu') {
$emit('得物推广', $data['dw'] ?? [], ['name' => fn ($row) => $row['shop_name'] . ' / ' . $row['account'], 'date' => fn ($row) => $row['trade_date'] ?? $row['date_time'], 'type' => fn ($row) => $row['flow_type'] ?? '', 'amount' => fn ($row) => $row['trade_amount_yuan'] ?? '', 'balance' => fn ($row) => $row['balance_yuan'] ?? '', 'remark' => fn ($row) => $row['remark'] ?? '']);
}
fclose($csv);
}, '店铺营销账户账单_' . $platform . '_' . $filters['month'] . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
private function exportMarketingAccountBills(
Request $request,
string $section,
string $page,
string $tab,
) {
abort_unless(in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet'], true), 404);
return match ($tab) {
'taobao-alipay' => $this->exportTaobaoAccountBills($request, $section, $page, $tab),
'taobao-wechat' => $this->exportAlibabaAccountBills($request, $section, $page, $tab),
'jd-wallet' => $this->exportJdAccountBills($request, $section, $page, $tab),
default => $this->exportPinduoduoAccountBills($request, $section, $page, $tab),
};
}
private function exportTaobaoAccountBills(Request $request, string $section, string $page, string $tab)
{
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$flowType = in_array($request->query('flow_type'), ['income', 'expense'], true)
? (string) $request->query('flow_type')
: '';
$redirectParameters = $this->marketingBillRedirectParameters($section, $page, $tab, [
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
'flow_type' => $flowType,
]);
$repository = app(TmallTaobaoSpreadBillRepository::class);
if (! $repository->usesBusinessMirror()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '淘系账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。');
}
return $this->streamMarketingAccountBillCsv(
$repository->exportRows(
$storeName !== '' ? $storeName : null,
$fromDate !== '' ? $fromDate : null,
$toDate !== '' ? $toDate : null,
$flowType !== '' ? $flowType : null,
),
['店铺名称', '记账日期', '交易日期', '金额(元)', '余额(元)', '备注', 'finType', '收支类型', '交易类型'],
static fn (array $row): array => [
$row['store_name'] ?? '', $row['bookkeeping_time'] ?? '', $row['transaction_date'] ?? '',
$row['amount'] ?? 0, $row['balance'] ?? 0, $row['comment'] ?? '', $row['fin_type'] ?? '',
$row['income_expense_type'] ?? '', $row['transaction_type'] ?? '',
],
'taobao_account_bills_' . $this->marketingBillRange($fromDate, $toDate) . '.csv',
$redirectParameters,
'淘系账单导出暂时无法完成,请稍后重试。',
);
}
private function exportAlibabaAccountBills(Request $request, string $section, string $page, string $tab)
{
$shopName = mb_substr(trim((string) $request->query('shop_name', '')), 0, 191);
$productType = mb_substr(trim((string) $request->query('product_type', '')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$redirectParameters = $this->marketingBillRedirectParameters($section, $page, $tab, [
'shop_name' => $shopName,
'product_type' => $productType,
'from' => $fromDate,
'to' => $toDate,
]);
$repository = app(AlibabaBillRepository::class);
if (! $repository->usesBusinessMirror()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '阿里巴巴账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。');
}
return $this->streamMarketingAccountBillCsv(
$repository->exportRows(
$shopName !== '' ? $shopName : null,
$productType !== '' ? $productType : null,
$fromDate !== '' ? $fromDate : null,
$toDate !== '' ? $toDate : null,
),
['店铺名称', '交易时间', '产品类型', '入账金额(元)', '出帐金额(元)'],
static fn (array $row): array => [
$row['shop_name'] ?? '', $row['trade_day'] ?? '', $row['product_type'] ?? '',
$row['income_amount'] ?? 0, $row['expense_amount'] ?? 0,
],
'alibaba_account_bills_' . $this->marketingBillRange($fromDate, $toDate) . '.csv',
$redirectParameters,
'阿里巴巴账单导出暂时无法完成,请稍后重试。',
);
}
private function exportJdAccountBills(Request $request, string $section, string $page, string $tab)
{
$ledger = $request->query('jd_ledger') === 'consumption' ? 'consumption' : 'recharge';
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$redirectParameters = $this->marketingBillRedirectParameters($section, $page, $tab, [
'jd_ledger' => $ledger,
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
]);
$repository = app(JdWalletBillRepository::class);
if (! $repository->usesBusinessMirror($ledger)) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '京东账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。');
}
$headers = $ledger === 'consumption'
? ['店铺名称', '金额(元)', '余额(元)', '交易类型', '投放日期', '结算日期', '流水号']
: ['店铺名称', '金额(元)', '余额(元)', '交易类型', '商户订单号', '充值时间', '到账时间', '流水号', '状态', '备注'];
$rowValues = $ledger === 'consumption'
? static fn (array $row): array => [
$row['store_name'] ?? '', $row['amount'] ?? 0, $row['balance'] ?? 0,
$row['transaction_type'] ?? '', $row['investment_date'] ?? '',
$row['settlement_date'] ?? '', $row['serial_number'] ?? '',
]
: static fn (array $row): array => [
$row['store_name'] ?? '', $row['amount'] ?? 0, $row['balance'] ?? 0,
$row['transaction_type'] ?? '', $row['order_number'] ?? '', $row['recharge_time'] ?? '',
$row['arrival_time'] ?? '', $row['serial_number'] ?? '', $row['status'] ?? '', $row['notes'] ?? '',
];
return $this->streamMarketingAccountBillCsv(
$repository->exportRows(
$ledger,
$storeName !== '' ? $storeName : null,
$fromDate !== '' ? $fromDate : null,
$toDate !== '' ? $toDate : null,
),
$headers,
$rowValues,
'jd_' . $ledger . '_account_bills_' . $this->marketingBillRange($fromDate, $toDate) . '.csv',
$redirectParameters,
'京东账单导出暂时无法完成,请稍后重试。',
);
}
private function exportPinduoduoAccountBills(Request $request, string $section, string $page, string $tab)
{
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$flowType = in_array($request->query('flow_type'), ['income', 'expense'], true)
? (string) $request->query('flow_type')
: '';
$redirectParameters = $this->marketingBillRedirectParameters($section, $page, $tab, [
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
'flow_type' => $flowType,
]);
$repository = app(PinduoduoSpreadBillRepository::class);
if (! $repository->usesBusinessMirror()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '拼多多账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。');
}
return $this->streamMarketingAccountBillCsv(
$repository->exportRows(
$storeName !== '' ? $storeName : null,
$fromDate !== '' ? $fromDate : null,
$toDate !== '' ? $toDate : null,
$flowType !== '' ? $flowType : null,
),
['店铺名称', '金额(元)', '余额(元)', '摘要', '收支类型', '资金类型', '费用时间'],
static fn (array $row): array => [
$row['store_name'] ?? '', $row['amount'] ?? 0, $row['balance'] ?? 0,
$row['summary'] ?? '', $row['income_expense_type'] ?? '',
$row['fund_type'] ?? '', $row['cost_time'] ?? '',
],
'pinduoduo_account_bills_' . $this->marketingBillRange($fromDate, $toDate) . '.csv',
$redirectParameters,
'拼多多账单导出暂时无法完成,请稍后重试。',
);
}
private function streamMarketingAccountBillCsv(
iterable $rows,
array $headers,
callable $rowValues,
string $filename,
array $redirectParameters,
string $errorMessage,
) {
$csv = null;
try {
$csv = fopen('php://temp/maxmemory:5242880', 'w+b');
if ($csv === false) {
throw new \RuntimeException('无法创建账单导出临时文件。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, $headers);
foreach ($rows as $row) {
fputcsv($csv, $rowValues($row));
}
rewind($csv);
} catch (Throwable $exception) {
if (is_resource($csv)) {
fclose($csv);
}
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', $errorMessage);
}
return response()->streamDownload(function () use ($csv): void {
fpassthru($csv);
fclose($csv);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
private function marketingBillRedirectParameters(string $section, string $page, string $tab, array $filters): array
{
return array_filter(array_merge([
'section' => $section,
'page' => $page,
'tab' => $tab,
], $filters), static fn (mixed $value): bool => $value !== '' && $value !== null);
}
private function marketingBillRange(string $fromDate, string $toDate): string
{
return ($fromDate ?: 'all') . '_' . ($toDate ?: 'all');
}
protected function contentForBoardDeductions(): array
{
$filters = [
'stock_in_date' => $this->dateFilter(request('stock_in_date')),
'stock_out_date' => $this->dateFilter(request('stock_out_date')),
'furniture_factory' => mb_substr(trim((string) request('furniture_factory', '')), 0, 191),
'lamination_factory' => mb_substr(trim((string) request('lamination_factory', '')), 0, 191),
'deduction_type' => mb_substr(trim((string) request('deduction_type', '')), 0, 191),
'material_name' => mb_substr(trim((string) request('material_name', '')), 0, 191),
'claim_number' => mb_substr(trim((string) request('claim_number', '')), 0, 191),
];
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$cache = $this->upstreamCaches->forSource('jiansun');
try {
$result = $cache->remember(
'supplier-payables.board-deductions.list',
['filters' => $filters, 'page' => $page, 'limit' => $limit],
fn () => $this->supplierPayableReports->boardDeductions($filters, $page, $limit),
);
$rows = $this->supplierPayableOverrides->decorate(
'board-deductions',
(array) ($result['rows'] ?? []),
(int) (company_id() ?: 1),
);
return [
'boardDeductions' => $this->supplierPayablePaginator($rows, $limit, $page),
'boardDeductionFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'),
'boardDeductionStatus' => $cache->responseHeaders(),
'boardDeductionError' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return [
'boardDeductions' => $this->supplierPayablePaginator([], $limit, $page),
'boardDeductionFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'),
'boardDeductionStatus' => [],
'boardDeductionError' => $exception->getMessage(),
];
} catch (Throwable $exception) {
report($exception);
return [
'boardDeductions' => $this->supplierPayablePaginator([], $limit, $page),
'boardDeductionFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'),
'boardDeductionStatus' => [],
'boardDeductionError' => '板材扣款数据暂时无法读取,请稍后重试。',
];
}
}
protected function contentForSupplierPayablesReport(): array
{
$month = trim((string) request('month', ''));
$filters = [
'month' => preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : '',
'supplier' => mb_substr(trim((string) request('supplier', '')), 0, 191),
'checker' => mb_substr(trim((string) request('checker', '')), 0, 191),
'settlement_method' => mb_substr(trim((string) request('settlement_method', '')), 0, 191),
];
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$cache = $this->upstreamCaches->forSource('jiansun');
try {
$result = $cache->remember(
'supplier-payables.report.list',
['filters' => $filters, 'page' => $page, 'limit' => $limit],
fn () => $this->supplierPayableReports->supplierPayables($filters, $page, $limit),
);
$rows = $this->supplierPayableOverrides->decorate(
'supplier-payables-report',
(array) ($result['rows'] ?? []),
(int) (company_id() ?: 1),
);
return [
'supplierPayables' => $this->supplierPayablePaginator($rows, $limit, $page),
'supplierPayableFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'),
'supplierPayableStatus' => $cache->responseHeaders(),
'supplierPayableError' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return [
'supplierPayables' => $this->supplierPayablePaginator([], $limit, $page),
'supplierPayableFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'),
'supplierPayableStatus' => [],
'supplierPayableError' => $exception->getMessage(),
];
} catch (Throwable $exception) {
report($exception);
return [
'supplierPayables' => $this->supplierPayablePaginator([], $limit, $page),
'supplierPayableFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'),
'supplierPayableStatus' => [],
'supplierPayableError' => '供应商应付数据暂时无法读取,请稍后重试。',
];
}
}
private function supplierPayablePaginator(array $rows, int $limit, int $page): Paginator
{
return new Paginator(
Collection::make($rows)->map(static fn (array $row): object => (object) $row),
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
);
}
protected function contentForNormalProducts(): array
{
$filters = $this->normalProductFilters(request());
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 50);
try {
if (! $this->productCatalog->available()) {
return $this->emptyProductCatalogContent(
$filters,
$limit,
$page,
'共享业务镜像尚未建立,普通商品资料暂时无法读取。',
null,
['code' => 'unavailable', 'lag_minutes' => null],
);
}
$state = $this->productCatalog->state();
$syncStatus = $this->productCatalog->syncStatus($state);
if (! $this->productCatalog->ready()) {
return $this->emptyProductCatalogContent(
$filters,
$limit,
$page,
'普通商品资料正在执行首次全量同步,完成前不会把未同步数据误判为无数据。',
$state,
$syncStatus,
);
}
$result = $this->productCatalog->paginate($filters, $page, $limit);
$paginator = new LengthAwarePaginator(
$result['data'],
$result['total'],
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
);
return [
'productCatalogRows' => $paginator,
'productCatalogFilters' => $filters,
'productCatalogError' => null,
'productCatalogState' => $state,
'productCatalogSyncStatus' => $syncStatus,
'productCatalogPurchasePriceSyncStatus' => $result['purchase_price_sync_status'] ?? [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogWorkPlatformPriceSyncStatus' => $result['work_platform_price_sync_status'] ?? [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogPriceChangeSyncStatus' => $result['price_change_sync_status'] ?? [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogJushuitanCostPriceHistorySyncStatus' => $result['jushuitan_cost_price_history_sync_status'] ?? [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
];
} catch (Throwable $exception) {
report($exception);
return $this->emptyProductCatalogContent(
$filters,
$limit,
$page,
'共享业务数据暂时无法读取,请稍后重试。',
null,
['code' => 'unavailable', 'lag_minutes' => null],
);
}
}
public function exportNormalProducts(Request $request): StreamedResponse
{
abort_unless($this->productCatalog->available() && $this->productCatalog->ready(), 503);
$filters = $this->normalProductFilters($request);
return response()->streamDownload(function () use ($filters): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建普通商品资料导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, [
'ID', '款式编码', '商品编码', '商品名称', '商品简称', '规格 / 颜色',
'品牌', '类目', '供应商', '销售价', '成本价', '普通商品采购价',
'工作平台价格', '工作平台价格生效日期', '定价状态', '变价前价格', '变价后价格', '差额', '变价时间',
'数据来源', '市场价', '商品状态', '库存同步', '业务更新时间',
]);
foreach ($this->productCatalog->exportRows($filters) as $row) {
$specification = collect([$row->properties_value, $row->color])
->filter(static fn (mixed $value): bool => $value !== null && $value !== '')
->unique()
->implode(' / ');
$enabledLabel = match ((int) $row->enabled) {
1 => '已启用',
-1 => '已删除',
default => '已停用',
};
fputcsv($csv, [
$row->autoid,
$this->csvText($row->i_id),
$this->csvText($row->sku_id),
$this->csvText($row->name),
$this->csvText($row->short_name),
$this->csvText($specification),
$this->csvText($row->brand),
$this->csvText($row->category),
$this->csvText($row->supplier_name),
$row->sale_price,
$row->cost_price,
$row->purchase_price,
$row->work_platform_price ?? null,
$this->workPlatformPriceDate($row->work_platform_price_effect_time ?? null),
$row->pricing_status ?? '未定价',
$row->price_change_before_price ?? null,
$row->price_change_after_price ?? null,
$row->price_change_diff_amount ?? null,
$this->workPlatformPriceDate($row->price_change_effect_time ?? null),
implode(';', $row->data_sources),
$row->market_price,
$enabledLabel,
(int) $row->stock_disabled === 0 ? '正常同步' : '停止同步',
$row->modified,
]);
}
fclose($csv);
}, '普通商品资料_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
private function workPlatformPriceDate(mixed $value): string
{
if (! is_string($value) || trim($value) === '') {
return '';
}
try {
return Carbon::parse($value)->format('Y-m-d');
} catch (Throwable) {
return '';
}
}
public function exportCourierInformation(Request $request): StreamedResponse
{
abort_unless($this->courierInformation->available() && $this->courierInformation->ready(), 503);
$filters = $this->courierInformationFilters($request);
return response()->streamDownload(function () use ($filters): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建快递资料导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, [
'ID', '快递公司代码', '快递公司名称', '外部编码', '杉橙快递代码', '运费打款方式',
'是否启用', '可达服务', '是否快运', '自动寻号', '配货单号寻号', '数量拆单', '重量0寻号',
'停运时间', '合同开始时间', '合同结束时间', '联系人', '联系电话', '自定义1', '自定义2', '自定义3',
'体积0寻号', '手工单号', '解密', '电子签章经办人', '最后修改',
]);
$bool = static fn (mixed $value): string => in_array(strtolower((string) $value), ['1', 'true', 'yes', 'y'], true) || $value === true ? '是' : '否';
$date = static fn (mixed $value): string => $value === null || $value === '' ? '' : substr((string) $value, 0, 10);
foreach ($this->courierInformation->exportRows($filters) as $row) {
fputcsv($csv, [
$row->Id,
$this->csvText($row->expcode),
$this->csvText($row->expname),
$this->csvText($row->outcode),
$this->csvText($row->scexpcode),
$this->csvText($row->paymethod),
$bool($row->status),
$bool($row->addvalidsgn),
$bool($row->kuaiyunsgn),
$bool($row->autonosgn),
$bool($row->packnosgn),
$bool($row->skucountsplitsgn),
$bool($row->weight0sgn),
$date($row->stopdate),
$date($row->tradebtime),
$date($row->tradeetime),
$this->csvText($row->linker),
$this->csvText($row->linkmobile),
$this->csvText($row->undefined1),
$this->csvText($row->undefined2),
$this->csvText($row->undefined3),
$bool($row->volume0sgn),
$bool($row->handnosgn),
$bool($row->decryptsgn),
$this->csvText($row->dzqzjbr),
$date($row->modified),
]);
}
fclose($csv);
}, '快递资料_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @return array<string,mixed> */
private function normalProductFilters(Request $request): array
{
$filters = [
'i_id' => mb_substr(trim((string) $request->query('i_id', '')), 0, 50),
'sku_id' => mb_substr(trim((string) $request->query('sku_id', '')), 0, 50),
'keyword' => mb_substr(trim((string) $request->query('keyword', '')), 0, 100),
'brand' => mb_substr(trim((string) $request->query('brand', '')), 0, 100),
'category' => mb_substr(trim((string) $request->query('category', '')), 0, 100),
'supplier_name' => mb_substr(trim((string) $request->query('supplier_name', '')), 0, 191),
'enabled' => in_array((string) $request->query('enabled', ''), ['-1', '0', '1'], true)
? (string) $request->query('enabled')
: '',
'stock_disabled' => in_array((string) $request->query('stock_disabled', ''), ['0', '1'], true)
? (string) $request->query('stock_disabled')
: '',
'sale_price_min' => $this->decimalFilter($request->query('sale_price_min')),
'sale_price_max' => $this->decimalFilter($request->query('sale_price_max')),
];
if ($filters['sale_price_min'] !== null
&& $filters['sale_price_max'] !== null
&& (float) $filters['sale_price_min'] > (float) $filters['sale_price_max']) {
[$filters['sale_price_min'], $filters['sale_price_max']] = [
$filters['sale_price_max'],
$filters['sale_price_min'],
];
}
return $filters;
}
private function csvText(mixed $value): string
{
$text = trim((string) ($value ?? ''));
return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text;
}
/**
* XLSX 文本单元格值:空值返回 null(跳过单元格)。
* 文本单元格不会被当作公式执行,因此不需要 csvText 的公式前缀保护。
*/
private function xlsxText(mixed $value): ?string
{
$text = trim((string) ($value ?? ''));
return $text === '' ? null : $text;
}
/**
* XLSX 日期时间单元格值:去掉 SQL Server 微秒尾缀(.000000),空值返回 null。
*/
private function xlsxDateTime(mixed $value): ?string
{
$text = trim((string) ($value ?? ''));
if ($text === '') {
return null;
}
return preg_replace('/\.\d{1,6}$/', '', $text) ?? $text;
}
/**
* Keep client-selected exports within the fields declared by the page.
* An omitted or invalid selection falls back to the complete safe schema.
*
* @param array<int,array{label:string,field:string}> $columns
* @return array<int,array{label:string,field:string}>
*/
private function filterExportColumns(array $columns, mixed $requestedFields): array
{
if (! is_string($requestedFields) || trim($requestedFields) === '') {
return $columns;
}
$available = [];
foreach ($columns as $column) {
if ($column['field'] !== '') {
$available[$column['field']] = $column;
}
}
$selected = [];
foreach (explode(',', $requestedFields) as $field) {
$field = trim($field);
if ($field !== '' && isset($available[$field])) {
$selected[$field] = $available[$field];
}
}
return $selected !== [] ? array_values($selected) : $columns;
}
/**
* Export identifiers as text so Excel does not round or truncate long IDs.
* Keep the formula-injection guard from csvText for values beginning with a
* formula marker.
*/
private function csvIdentifier(mixed $value): string
{
$text = $this->csvText($value);
if ($text === '' || str_starts_with($text, "'")) {
return $text;
}
return "'" . $text;
}
private function emptyProductCatalogContent(
array $filters,
int $limit,
int $page,
string $error,
?array $state,
array $syncStatus,
): array {
return [
'productCatalogRows' => new LengthAwarePaginator(
[],
0,
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
),
'productCatalogFilters' => $filters,
'productCatalogError' => $error,
'productCatalogState' => $state,
'productCatalogSyncStatus' => $syncStatus,
'productCatalogPurchasePriceSyncStatus' => [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogWorkPlatformPriceSyncStatus' => [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogPriceChangeSyncStatus' => [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogJushuitanCostPriceHistorySyncStatus' => [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
];
}
protected function contentForCourierInformation(): array
{
$filters = $this->courierInformationFilters(request());
$tab = (string) request()->query('courier_tab', 'express');
$tab = in_array($tab, ['express', 'networks', 'accounts', 'logs'], true) ? $tab : 'express';
$page = max((int) request()->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(),
'query' => request()->except('page'),
]);
$error = null;
$pending = false;
$state = null;
$syncStatus = ['code' => 'unavailable', 'lag_minutes' => null];
$relatedAvailability = $this->courierInformation->relatedAvailability();
try {
if ($tab === 'express') {
if (! $this->courierInformation->available()) {
$error = '共享业务镜像尚未建立,快递资料暂时无法读取。';
} else {
$state = $this->courierInformation->state();
$syncStatus = $this->courierInformation->syncStatus($state);
if (! $this->courierInformation->ready()) {
$pending = true;
$error = '快递资料正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierInformation->paginate($filters, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
}
}
} elseif ($tab === 'networks') {
if (! ($relatedAvailability['networks'] ?? false)) {
$error = '快递网点镜像尚未建立,请先完成 wp_express_expNet 的首次同步。';
} elseif (! ($relatedAvailability['network_ready'] ?? false)) {
$pending = true;
$error = '快递网点正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierInformation->paginateNetworks($filters['expcode'] ?: null, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
}
} elseif ($tab === 'accounts') {
if (! ($relatedAvailability['accounts'] ?? false)) {
$error = '打款账户镜像尚未建立,请先完成 wp_express_account 的首次同步。';
} elseif (! ($relatedAvailability['account_ready'] ?? false)) {
$pending = true;
$error = '打款账户正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierInformation->paginateAccounts($filters['expcode'] ?: null, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
}
} else {
if (! ($relatedAvailability['logs'] ?? false)) {
$error = '操作日志镜像尚未建立,请先完成 wp_express_extension 的首次同步。';
} elseif (! ($relatedAvailability['log_ready'] ?? false)) {
$pending = true;
$error = '操作日志正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierInformation->paginateLogs($filters['expcode'] ?: null, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
}
}
} catch (Throwable $exception) {
report($exception);
$error = '快递资料共享镜像暂时无法读取,请稍后重试。';
}
return [
'view' => 'business.pages.courier-payables.courier-information',
'courierTab' => $tab,
'courierFilters' => $filters,
'courierRows' => $rows,
'courierState' => $state,
'courierSyncStatus' => $syncStatus,
'courierError' => $error,
'courierPending' => $pending,
'courierRelatedAvailability' => $relatedAvailability,
];
}
/** @return array<string,string> */
private function courierInformationFilters(Request $request): array
{
$filters = [
'expcode' => mb_substr(trim((string) $request->query('expcode', '')), 0, 64),
'expname' => mb_substr(trim((string) $request->query('expname', '')), 0, 100),
'undefined1' => mb_substr(trim((string) $request->query('undefined1', '')), 0, 100),
'undefined2' => mb_substr(trim((string) $request->query('undefined2', '')), 0, 100),
'undefined3' => mb_substr(trim((string) $request->query('undefined3', '')), 0, 100),
'tradebtime_from' => $this->dateFilter($request->query('tradebtime_from')),
'tradebtime_to' => $this->dateFilter($request->query('tradebtime_to')),
'status' => in_array((string) $request->query('status', ''), ['0', '1'], true)
? (string) $request->query('status')
: '',
];
if ($filters['tradebtime_from'] !== ''
&& $filters['tradebtime_to'] !== ''
&& $filters['tradebtime_from'] > $filters['tradebtime_to']) {
[$filters['tradebtime_from'], $filters['tradebtime_to']] = [
$filters['tradebtime_to'],
$filters['tradebtime_from'],
];
}
return $filters;
}
private function courierPaginator(array $rows, int $total, int $limit, int $page): LengthAwarePaginator
{
return new LengthAwarePaginator(
Collection::make($rows),
$total,
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
);
}
protected function contentForReturnPickupManagement(): array
{
$filters = $this->returnPickupFilters(request());
$page = max((int) request('pickup_page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$repository = app(\Modules\EcommerceFinance\Repositories\ReturnPickupManagementMirrorRepository::class);
$rows = null;
$state = null;
$syncStatus = ['code' => 'unavailable', 'lag_minutes' => null];
$error = null;
$pending = false;
$carriers = [];
$statuses = [];
$detail = null;
try {
if (! $repository->available()) {
$error = '共享业务镜像尚未建立,退货取件管理暂时无法读取。';
} else {
$state = $repository->state();
$syncStatus = $repository->syncStatus($state);
if (! $repository->ready()) {
$pending = true;
$error = '退货取件管理正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $repository->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('pickup_page'),
],
);
$carriers = $repository->carriers();
$statuses = $repository->statuses();
$detailId = (int) request('pickup_id', 0);
if ($detailId > 0) {
$detail = $this->enrichReturnPickupDetail(
$repository->detail($detailId),
$repository->warehouseDirectory(),
);
}
}
}
} catch (Throwable $exception) {
report($exception);
$error = '退货取件管理共享镜像暂时无法读取,请稍后重试。';
}
return [
'view' => 'business.pages.courier-payables.return-pickup-management',
'returnPickupFilters' => $filters,
'returnPickupRows' => $rows,
'returnPickupState' => $state,
'returnPickupSyncStatus' => $syncStatus,
'returnPickupError' => $error,
'returnPickupPending' => $pending,
'returnPickupCarriers' => $carriers,
'returnPickupStatuses' => $statuses,
'returnPickupDetail' => $detail,
];
}
/** @return array<string,string> */
private function returnPickupFilters(Request $request): array
{
$filters = [
'created_from' => $this->dateFilter($request->query('created_from')),
'created_to' => $this->dateFilter($request->query('created_to')),
'carrier' => mb_substr(trim((string) $request->query('carrier', '')), 0, 64),
'status' => mb_substr(trim((string) $request->query('status', '')), 0, 64),
'shop' => mb_substr(trim((string) $request->query('shop', '')), 0, 191),
'keyword' => mb_substr(trim((string) $request->query('keyword', '')), 0, 100),
];
if ($filters['created_from'] !== ''
&& $filters['created_to'] !== ''
&& $filters['created_from'] > $filters['created_to']) {
[$filters['created_from'], $filters['created_to']] = [
$filters['created_to'],
$filters['created_from'],
];
}
return $filters;
}
/**
* 用逆向物流报价为退货取件明细补「实际运费」和「差异(预估−实际)」。
*
* @param array{main:object,items:list<object>,truth_numbers:list<string>}|null $detail
* @param array<string,string> $warehouseDirectory 发货仓编码 → 名称
* @return array<string,mixed>|null
*/
private function enrichReturnPickupDetail(?array $detail, array $warehouseDirectory = []): ?array
{
if ($detail === null) {
return null;
}
try {
$calculator = app(\App\Support\ReturnPickupFreightCalculator::class);
} catch (Throwable $exception) {
report($exception);
$calculator = null;
}
$main = $detail['main'];
$items = [];
$estimatedTotal = 0.0;
$actualTotal = 0.0;
$surchargeTotal = 0.0;
$hasActual = false;
foreach ($detail['items'] as $item) {
$result = $calculator?->calculate(
$main->express_company ?? null,
$main->province ?? null,
$item->receiverAddress ?? null,
(float) ($item->grossWeight ?? 0),
$item->length ?? null,
$item->width ?? null,
$item->height ?? null,
) ?? ['covered' => false, 'reason' => '逆向物流报价快照不可用', 'total' => null, 'surcharge' => 0.0, 'billable' => null, 'formula' => null, 'note' => null, 'surcharge_note' => null];
$estimated = $item->estimatedFreight !== null ? (float) $item->estimatedFreight : null;
$actual = $result['total'] !== null ? (float) $result['total'] : null;
$surcharge = (float) ($result['surcharge'] ?? 0);
$values = (array) $item;
$values['actual_freight'] = $actual;
$values['freight_surcharge'] = $surcharge;
$values['freight_settlement'] = $result['settlement'];
$values['freight_base_amount'] = $result['base_amount'];
$values['freight_increment_amount'] = $result['increment_amount'];
$values['freight_billable'] = $result['billable'];
$values['freight_formula'] = $result['formula'];
$values['freight_note'] = $result['covered']
? trim(($result['note'] ?? '') . ($result['surcharge_note'] !== null ? ';' . $result['surcharge_note'] : ''))
: $result['reason'];
$values['freight_process'] = $result['process'] ?? $result['reason'];
$values['freight_covered'] = $result['covered'];
$values['freight_diff'] = ($estimated !== null && $actual !== null) ? round($estimated - $actual, 2) : null;
$warehouseCode = trim((string) ($values['warehouse'] ?? ''));
$values['warehouse_name'] = $warehouseCode === ''
? null
: ($warehouseDirectory[$warehouseCode] ?? null);
$items[] = (object) $values;
if ($estimated !== null) {
$estimatedTotal += $estimated;
}
if ($actual !== null) {
$actualTotal += $actual;
$hasActual = true;
}
$surchargeTotal += $surcharge;
}
$detail['items'] = $items;
$detail['totals'] = [
'estimated' => round($estimatedTotal, 2),
'actual' => $hasActual ? round($actualTotal, 2) : null,
'surcharge' => round($surchargeTotal, 2),
'diff' => $hasActual ? round($estimatedTotal - $actualTotal, 2) : null,
];
return $detail;
}
public function exportReturnPickupManagement(Request $request): StreamedResponse
{
$filters = $this->returnPickupFilters($request);
$repository = app(\Modules\EcommerceFinance\Repositories\ReturnPickupManagementMirrorRepository::class);
$calculator = app(\App\Support\ReturnPickupFreightCalculator::class);
$limit = 50000;
return response()->streamDownload(function () use ($filters, $repository, $calculator, $limit): void {
$writer = new \App\Support\StreamingXlsxWriter('退货取件明细');
$writer->appendRow([
'订单号', '快递公司', '订单状态', '紧急程度', '店铺名称', '交易单号', '出发省', '到达省',
'应退货号', '实退货号', '条形码', '品名', '平台商品id', '平台skuid',
'应退数量', '实退数量', '待核销数量', '总件数', '长', '宽', '高',
'毛重(kg)', '体积重(kg)', '重量取大(kg)', '计费重量(kg)', '面单(元)', '费率(元/kg)',
'预估运费(元)', '实际运费(元)', '加收(元)', '差异(元)',
'明细退货进度', '发货仓', '收货人', '收货电话', '收货地址', '实退单号', '实退快递单号',
'预计取件时间', '退货进度', '付款方式', '取件类型', '发件人姓名', '备注',
'问题类型', '问题描述', '问题跟进状态', '问题跟进结果', '问题跟进说明',
'快递下单时间', '快递取件时间', '创建人', '取消人', '创建时间', '更新时间',
]);
$warehouseDirectory = $repository->warehouseDirectory();
$number = static fn (?float $value): ?float => $value === null ? null : round($value, 2);
$quantity = static fn (mixed $value): ?int => $value === null ? null : (int) $value;
$exported = 0;
foreach ($repository->exportRows($filters, $limit) as $row) {
$length = (float) ($row->length ?? 0);
$width = (float) ($row->width ?? 0);
$height = (float) ($row->height ?? 0);
$volumeWeight = ($length > 0 && $width > 0 && $height > 0)
? round($length * $width * $height / 6000, 2)
: null;
$result = $calculator->calculate(
$row->express_company ?? null,
$row->province ?? null,
$row->receiverAddress ?? null,
(float) ($row->grossWeight ?? 0),
$length,
$width,
$height,
);
$estimated = $row->estimatedFreight !== null ? round((float) $row->estimatedFreight, 2) : null;
$actual = $result['total'];
$warehouseCode = trim((string) ($row->warehouse ?? ''));
$followStatus = $this->xlsxText($row->expressFollowStatus ?? '');
$followResult = $this->xlsxText($row->expressFollowResult ?? '');
$returnProgress = $followStatus === null
? $followResult
: ($followResult === null ? $followStatus : $followStatus . '(' . $followResult . ')');
$writer->appendRow([
$this->xlsxText($row->orderId ?? ''),
$this->xlsxText($row->express_company ?? ''),
$this->xlsxText(\App\Support\ReturnPickupOrderStatus::label($row->orderStatus ?? null)),
$this->xlsxText($row->urgencyLevel ?? ''),
$this->xlsxText($row->shopName ?? ''),
$this->xlsxText($row->transactionNo ?? ''),
$this->xlsxText($row->province ?? ''),
$this->xlsxText($result['covered']
? \App\Support\ReverseLogisticsQuoteSnapshot::provinceKey(
\App\Support\ReturnPickupFreightCalculator::destinationProvince((string) ($row->receiverAddress ?? ''))
)
: ''),
$this->xlsxText($row->returnProcode ?? ''),
$this->xlsxText($row->actualReturnProcode ?? ''),
$this->xlsxText($row->barcode ?? ''),
$this->xlsxText($row->productName ?? ''),
$this->xlsxText($row->platformProductId ?? ''),
$this->xlsxText($row->platformSkuId ?? ''),
$quantity($row->returnQuantity ?? null),
$quantity($row->actualReturnQuantity ?? null),
$quantity($row->unverifiedQuantity ?? null),
$quantity($row->totalReturnQuantity ?? null),
$number($length > 0 ? $length : null),
$number($width > 0 ? $width : null),
$number($height > 0 ? $height : null),
$number($row->grossWeight !== null ? (float) $row->grossWeight : null),
$number($volumeWeight),
$number($result['settlement']),
$number($result['billable']),
$number($result['base_amount']),
$number($result['increment_amount']),
$number($estimated),
$number($actual),
$number(($result['surcharge'] ?? 0) > 0 ? (float) $result['surcharge'] : null),
$number($estimated !== null && $actual !== null ? round($estimated - $actual, 2) : null),
$this->xlsxText($row->returnProgress ?? ''),
$this->xlsxText($warehouseCode === '' ? '' : ($warehouseDirectory[$warehouseCode] ?? $warehouseCode)),
$this->xlsxText($row->receiverName ?? ''),
$this->xlsxText($row->receiverPhone ?? ''),
$this->xlsxText($row->receiverAddress ?? ''),
$this->xlsxText($row->actualReturnOrderNo ?? ''),
$this->xlsxText($row->returnExpressNo ?? ''),
$this->xlsxDateTime($row->expectedPickupTime ?? null),
$this->xlsxText($returnProgress ?? ''),
$this->xlsxText($row->paymentMethod ?? ''),
$this->xlsxText($row->pickupType ?? ''),
$this->xlsxText($row->senderName ?? ''),
$this->xlsxText($row->note ?? ''),
$this->xlsxText($row->issueType ?? ''),
$this->xlsxText($row->issueDescription ?? ''),
$this->xlsxText($row->expressFollowStatus ?? ''),
$this->xlsxText($row->expressFollowResult ?? ''),
$this->xlsxText($row->expressFollowDescription ?? ''),
$this->xlsxDateTime($row->expressOrderTime ?? null),
$this->xlsxDateTime($row->expressPickupTime ?? null),
$this->xlsxText($row->creator ?? ''),
$this->xlsxText($row->canceler ?? ''),
$this->xlsxDateTime($row->createTime ?? null),
$this->xlsxDateTime($row->updateTime ?? null),
]);
$exported++;
}
if ($exported >= $limit) {
$writer->appendRow(['(已按上限截断,仅导出前 ' . $limit . ' 行,请缩小筛选范围后重试)']);
}
$output = fopen('php://output', 'wb');
if ($output === false) {
throw new RuntimeException('无法创建退货取件管理导出流。');
}
$writer->writeTo($output);
fclose($output);
}, '退货取件管理_' . now()->format('Ymd_His') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
protected function contentForProductPrices(): array
{
$filters = [
'barcode' => mb_substr(trim((string) request('barcode', '')), 0, 191),
'completed_from' => $this->dateFilter(request('completed_from')),
'completed_to' => $this->dateFilter(request('completed_to')),
];
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
try {
if (! $this->productPrices->ready()) {
return $this->emptyProductPriceContent(
$filters,
$limit,
$page,
'共享业务库尚未完成建库,请等待负责人完成初始化。'
);
}
if (! $this->productPrices->initialized()) {
return $this->emptyProductPriceContent(
$filters,
$limit,
$page,
'商品价格正在执行首次全量同步,完成前不会把未同步数据误判为无数据。'
);
}
$paginator = $this->productPrices->paginate($filters, $page, $limit);
$paginator->appends(request()->except('page'));
return [
'productPrices' => $paginator,
'productPriceFilters' => $filters,
'productPriceError' => null,
];
} catch (Throwable $exception) {
report($exception);
return $this->emptyProductPriceContent(
$filters,
$limit,
$page,
'共享业务数据暂时无法读取,请稍后重试。'
);
}
}
private function emptyProductPriceContent(array $filters, int $limit, int $page, string $error): array
{
return [
'productPrices' => new LengthAwarePaginator(
[],
0,
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
),
'productPriceFilters' => $filters,
'productPriceError' => $error,
];
}
/**
* 封边条页面只读取上游快照;未启用或无快照时将错误交给页面显示,
* 避免工作台因上游暂不可用直接返回 500。
*/
protected function edgeBandingContent(string $page): ?array
{
$scope = match ($page) {
'edge-banding-prices' => 'edge-banding.prices',
'details' => 'edge-banding.details',
'edge-banding-payables' => 'edge-banding.payables',
default => null,
};
if ($scope === null) {
return null;
}
$filters = match ($page) {
'edge-banding-prices' => $this->edgeBandingFilters([
'raw_name', 'raw_code', 'production_code', 'supplier',
]),
'details' => $this->edgeBandingFilters([
'date', 'production_code', 'raw_name', 'return_flag',
'outbound_supplier', 'arrival_supplier', 'outbound_no',
]),
'edge-banding-payables' => $this->edgeBandingFilters(['period', 'supplier']),
default => [],
};
if ($page === 'edge-banding-payables' && ! isset($filters['period'])) {
$filters['period'] = '2026-06';
}
$pageNumber = max((int) request('data_page', 1), 1);
$limit = min(max((int) request('data_limit', 25), 1), 100);
$cache = $this->upstreamCaches->forSource(self::EDGE_BANDING_SOURCE);
try {
$result = $cache->remember(
$scope,
['filters' => $filters, 'page' => $pageNumber, 'limit' => $limit],
fn () => match ($scope) {
'edge-banding.prices' => $this->edgeBanding->prices($filters, $pageNumber, $limit),
'edge-banding.details' => $this->edgeBanding->details($filters, $pageNumber, $limit),
'edge-banding.payables' => $this->edgeBanding->payables($filters, $pageNumber, $limit),
},
);
return [
'filters' => $filters,
'result' => $result,
'page' => $pageNumber,
'limit' => $limit,
'last_page' => max((int) ceil(max((int) ($result['total'] ?? 0), 0) / $limit), 1),
'status' => $cache->responseHeaders(),
'error' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return [
'filters' => $filters,
'result' => ['data' => [], 'total' => 0],
'page' => $pageNumber,
'limit' => $limit,
'last_page' => 1,
'status' => [],
'error' => $exception->getMessage(),
];
} catch (Throwable $exception) {
report($exception);
return [
'filters' => $filters,
'result' => ['data' => [], 'total' => 0],
'page' => $pageNumber,
'limit' => $limit,
'last_page' => 1,
'status' => [],
'error' => '封边条数据暂时无法读取,请稍后重试。',
];
}
}
private function edgeBandingFilters(array $fields): array
{
$filters = [];
foreach ($fields as $field) {
$value = trim((string) request($field, ''));
if ($value !== '') {
$filters[$field] = $value;
}
}
return $filters;
}
/**
* 素板价格与对账页面使用 JianSun 只读查询,并保留共享快照链路。
*/
protected function rawBoardContent(string $page): ?array
{
$scope = match ($page) {
'board-prices' => 'raw-board.prices',
'board-reconciliation' => 'raw-board.reconciliations',
default => null,
};
if ($scope === null) {
return null;
}
$filters = $scope === 'raw-board.prices'
? $this->rawBoardFilters(['raw_name', 'raw_code', 'production_code', 'supplier'])
: $this->rawBoardReconciliationFilters();
$pageNumber = max((int) request('data_page', 1), 1);
$limit = min(max((int) request('data_limit', 25), 1), 100);
try {
$cache = $this->upstreamCaches->forSource(self::RAW_BOARD_SOURCE);
$result = $cache->remember(
$scope,
['filters' => $filters, 'page' => $pageNumber, 'limit' => $limit],
fn () => $scope === 'raw-board.prices'
? $this->rawBoardPayables->prices($filters, $pageNumber, $limit)
: $this->rawBoardPayables->reconciliations($filters, $pageNumber, $limit),
);
return [
'filters' => $filters,
'result' => $result,
'page' => $pageNumber,
'limit' => $limit,
'last_page' => max((int) ceil(max((int) ($result['total'] ?? 0), 0) / $limit), 1),
'status' => $cache->responseHeaders(),
'error' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return $this->emptyRawBoardContent($filters, $pageNumber, $limit, $exception->getMessage());
} catch (Throwable $exception) {
report($exception);
return $this->emptyRawBoardContent(
$filters,
$pageNumber,
$limit,
'素板应付数据暂时无法读取,请稍后重试。',
);
}
}
private function rawBoardFilters(array $fields): array
{
$filters = [];
foreach ($fields as $field) {
$value = mb_substr(trim((string) request($field, '')), 0, 100);
if ($value !== '') {
$filters[$field] = $value;
}
}
return $filters;
}
private function rawBoardReconciliationFilters(): array
{
$filters = $this->rawBoardFilters([
'production_code', 'raw_name', 'outbound_supplier',
'arrival_supplier', 'outbound_no',
]);
foreach (['date_from', 'date_to'] as $field) {
$value = $this->dateFilter(request($field));
if ($value !== '') {
$filters[$field] = $value;
}
}
$returnFlag = trim((string) request('return_flag', ''));
if (in_array($returnFlag, ['normal', 'return'], true)) {
$filters['return_flag'] = $returnFlag;
}
return $filters;
}
private function emptyRawBoardContent(array $filters, int $page, int $limit, string $error): array
{
return [
'filters' => $filters,
'result' => ['data' => [], 'total' => 0, 'totals' => []],
'page' => $page,
'limit' => $limit,
'last_page' => 1,
'status' => [],
'error' => $error,
];
}
/**
* 板材库存页面使用 JianSun 最新素板快照,并保留生产共享快照链路。
*/
protected function inventoryContent(string $page): ?array
{
if ($page !== 'board-inventory-details') {
return null;
}
$filters = $this->inventoryFilters();
$pageNumber = max((int) request('data_page', 1), 1);
$limit = min(max((int) request('data_limit', 25), 1), 100);
try {
$cache = $this->upstreamCaches->forSource(self::INVENTORY_SOURCE);
$result = $cache->remember(
'inventory.board-details',
['filters' => $filters, 'page' => $pageNumber, 'limit' => $limit],
fn () => $this->inventory->boardInventoryDetails($filters, $pageNumber, $limit),
);
return [
'filters' => $filters,
'result' => $result,
'page' => $pageNumber,
'limit' => $limit,
'last_page' => max((int) ceil(max((int) ($result['total'] ?? 0), 0) / $limit), 1),
'status' => $cache->responseHeaders(),
'error' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return $this->emptyInventoryContent($filters, $pageNumber, $limit, $exception->getMessage());
} catch (Throwable $exception) {
report($exception);
return $this->emptyInventoryContent(
$filters,
$pageNumber,
$limit,
'板材库存数据暂时无法读取,请稍后重试。',
);
}
}
private function inventoryFilters(): array
{
$filters = [];
foreach (['manufacturer', 'specification', 'matching_value'] as $field) {
$value = mb_substr(trim((string) request($field, '')), 0, 100);
if ($value !== '') {
$filters[$field] = $value;
}
}
foreach (['date_from', 'date_to'] as $field) {
$value = $this->dateFilter(request($field));
if ($value !== '') {
$filters[$field] = $value;
}
}
return $filters;
}
private function emptyInventoryContent(array $filters, int $page, int $limit, string $error): array
{
return [
'filters' => $filters,
'result' => ['data' => [], 'total' => 0, 'totals' => []],
'page' => $page,
'limit' => $limit,
'last_page' => 1,
'status' => [],
'error' => $error,
];
}
/**
* 种菜应收对账 - 加载对账数据(21字段)
*/
protected function contentForZhongcaiReconciliation(): array
{
return [
'view' => 'store_receivables.zhongcai_reconciliations.partials.embedded-table',
'records' => StoreZhongcaiReconciliation::orderBy('bill_date', 'desc')
->orderBy('id', 'desc')
->paginate(setting('default.list_limit', 25)),
];
}
protected function contentForMarketingAccountBills(?string $tab): ?array
{
if (! in_array($tab, self::MARKETING_PLATFORMS, true)) {
return null;
}
if (in_array($tab, self::PROMOTION_MARKETING_TABS, true)) {
return app(PlatformPromotionAccountPageData::class)->content($tab);
}
// Alibaba 页面沿用历史 taobao-wechat 路由,数据来自 JianSun.dbo.alibaba_bill。
if ($tab === 'taobao-wechat') {
$request = request();
$shopName = mb_substr(trim((string) $request->query('shop_name')), 0, 191);
$productType = mb_substr(trim((string) $request->query('product_type')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$page = max(1, (int) $request->query('page', 1));
$pageSize = min(max((int) setting('default.list_limit', 25), 1), 100);
$filters = [
'shop_name' => $shopName !== '' ? $shopName : null,
'product_type' => $productType !== '' ? $productType : null,
'from' => $fromDate !== '' ? $fromDate : null,
'to' => $toDate !== '' ? $toDate : null,
];
$repository = app(AlibabaBillRepository::class);
$cache = $this->upstreamCaches->forSource('jiansun');
$usingMirror = $repository->usesBusinessMirror();
try {
$listResolver = fn () => $repository->paginate(
$filters['shop_name'], $filters['product_type'],
$filters['from'], $filters['to'], $page, $pageSize,
);
$summaryResolver = fn () => $repository->summary(
$filters['shop_name'], $filters['product_type'], $filters['from'], $filters['to'],
);
$optionsResolver = fn () => $repository->filterOptions();
$result = $usingMirror
? $listResolver()
: $cache->remember(
'marketing-account-bills.alibaba.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
$listResolver,
);
$summary = $usingMirror
? $summaryResolver()
: $cache->remember(
'marketing-account-bills.alibaba.summary',
['filters' => $filters],
$summaryResolver,
);
$options = $usingMirror
? $optionsResolver()
: $cache->remember(
'marketing-account-bills.alibaba.options',
[],
$optionsResolver,
);
} catch (ServiceUnavailableHttpException $exception) {
return [
'view' => 'banking.marketing_alibaba_bills.partials.embedded-table',
'bills' => new LengthAwarePaginator([], 0, $pageSize, $page, [
'path' => $request->url(), 'query' => $request->query(),
]),
'filters' => $filters,
'summary' => ['income_total' => 0, 'expense_total' => 0, 'net' => 0, 'count' => 0],
'options' => ['shops' => [], 'product_types' => []],
'error' => $exception->getMessage(),
];
} catch (Throwable $exception) {
report($exception);
return [
'view' => 'banking.marketing_alibaba_bills.partials.embedded-table',
'bills' => new LengthAwarePaginator([], 0, $pageSize, $page, [
'path' => $request->url(), 'query' => $request->query(),
]),
'filters' => $filters,
'summary' => ['income_total' => 0, 'expense_total' => 0, 'net' => 0, 'count' => 0],
'options' => ['shops' => [], 'product_types' => []],
'error' => 'Alibaba 账单数据暂时无法读取,请稍后重试。',
];
}
$bills = new LengthAwarePaginator(
Collection::make($result['rows'] ?? []),
(int) ($result['total'] ?? 0),
$pageSize,
$page,
['path' => $request->url(), 'query' => $request->query()],
);
return [
'view' => 'banking.marketing_alibaba_bills.partials.embedded-table',
'bills' => $bills,
'filters' => $filters,
'summary' => $summary,
'options' => $options,
'error' => null,
];
}
// 淘系支付宝 - 从上游 JianSun.tmall_spread_bills 读取并按表1字段展示(9列)
if ($tab === 'taobao-alipay') {
$request = request();
$storeName = mb_substr(trim((string) $request->query('store_name')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$flowType = trim((string) $request->query('flow_type'));
$flowType = in_array($flowType, ['income', 'expense'], true) ? $flowType : '';
$page = max(1, (int) $request->query('page', 1));
$pageSize = min(max((int) setting('default.list_limit', 25), 1), 100);
$repository = app(TmallTaobaoSpreadBillRepository::class);
$cache = $this->upstreamCaches->forSource('jiansun');
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'from' => $fromDate !== '' ? $fromDate : null,
'to' => $toDate !== '' ? $toDate : null,
'flow_type' => $flowType !== '' ? $flowType : null,
];
$listResolver = fn () => $repository->paginate(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type'],
$page,
$pageSize
);
$summaryResolver = fn () => $repository->summary(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type']
);
$optionsResolver = fn () => $repository->storeOptions();
$usingMirror = $repository->usesBusinessMirror();
$result = $usingMirror
? $listResolver()
: $cache->remember(
'taobao-alipay-bills.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
$listResolver,
);
$summary = $usingMirror
? $summaryResolver()
: $cache->remember(
'taobao-alipay-bills.summary',
['filters' => $filters],
$summaryResolver,
);
$storeOptions = $usingMirror
? $optionsResolver()
: $cache->remember(
'taobao-alipay-bills.options',
[],
$optionsResolver,
);
$bills = new LengthAwarePaginator(
Collection::make($result['rows']),
$result['total'],
$pageSize,
$page,
[
'path' => $request->url(),
'query' => $request->query(),
]
);
return [
'view' => 'banking.marketing_taobao_alipay_bills.partials.embedded-table',
'bills' => $bills,
'filters' => [
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
'flow_type' => $flowType,
'store_options' => $storeOptions,
],
'summary' => $summary,
];
}
// 拼多多 - 从上游 JianSun.pdd_spread_bills 读取并按表1字段展示(7列)
// 表1列:店铺名称 | 金额 | 余额 | 摘要 | 收支类型 | 资金类型 | 费用时间
if ($tab === 'pinduoduo') {
$request = request();
$storeName = mb_substr(trim((string) $request->query('store_name')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$flowType = trim((string) $request->query('flow_type'));
$flowType = in_array($flowType, ['income', 'expense'], true) ? $flowType : '';
$page = max(1, (int) $request->query('page', 1));
$pageSize = min(max((int) setting('default.list_limit', 25), 1), 100);
$repository = app(PinduoduoSpreadBillRepository::class);
$cache = $this->upstreamCaches->forSource('jiansun');
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'from' => $fromDate !== '' ? $fromDate : null,
'to' => $toDate !== '' ? $toDate : null,
'flow_type' => $flowType !== '' ? $flowType : null,
];
// 页面只读取共享查询快照;冷查询由统一限流和熔断链路访问上游。
$listResolver = fn () => $repository->paginate(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type'],
$page,
$pageSize
);
$summaryResolver = fn () => $repository->summary(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type']
);
$optionsResolver = fn () => $repository->storeOptions();
$usingMirror = $repository->usesBusinessMirror();
$result = $usingMirror
? $listResolver()
: $cache->remember(
'pinduoduo-bills.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
$listResolver,
);
$summary = $usingMirror
? $summaryResolver()
: $cache->remember(
'pinduoduo-bills.summary',
['filters' => $filters],
$summaryResolver,
);
$storeOptions = $usingMirror
? $optionsResolver()
: $cache->remember(
'pinduoduo-bills.options',
[],
$optionsResolver,
);
// 包装成 Laravel 分页器,便于 Blade 用 $bills->links()
$bills = new LengthAwarePaginator(
Collection::make($result['rows']),
$result['total'],
$pageSize,
$page,
[
'path' => $request->url(),
'query' => $request->query(),
]
);
return [
'view' => 'banking.marketing_pinduoduo_bills.partials.embedded-table',
'bills' => $bills,
'filters' => [
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
'flow_type' => $flowType,
'store_options' => $storeOptions,
],
'summary' => $summary,
];
}
// 京东钱包 - 按附件表1分别展示充值账单和消耗账单,数据来自 JianSun 上游只读表。
if ($tab === 'jd-wallet') {
$request = request();
$ledger = $request->query('jd_ledger') === 'consumption' ? 'consumption' : 'recharge';
$storeName = mb_substr(trim((string) $request->query('store_name')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$page = max(1, (int) $request->query('page', 1));
$pageSize = min(max((int) setting('default.list_limit', 25), 1), 100);
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'from' => $fromDate !== '' ? $fromDate : null,
'to' => $toDate !== '' ? $toDate : null,
];
$repository = app(JdWalletBillRepository::class);
$cache = $this->upstreamCaches->forSource('jiansun');
$scopePrefix = 'jd-wallet-bills.' . $ledger;
$errors = [];
$usingMirror = $repository->usesBusinessMirror($ledger);
$remember = function (string $scope, array $parameters, callable $resolver, mixed $fallback) use ($cache, $usingMirror, &$errors): mixed {
try {
return $usingMirror ? $resolver() : $cache->remember($scope, $parameters, $resolver);
} catch (ServiceUnavailableHttpException $exception) {
$errors[] = $exception->getMessage();
} catch (Throwable $exception) {
report($exception);
$errors[] = '京东账单数据暂时无法读取,请稍后重试。';
}
return $fallback;
};
// 三项分别读取,确保生产冷启动时一次请求就能登记列表、汇总和选项,
// 同时保留已经命中的共享快照,不因另一项暂缺而清空整页。
$result = $remember(
$scopePrefix . '.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
fn () => $ledger === 'consumption'
? $repository->paginateConsumption($filters['store_name'], $filters['from'], $filters['to'], $page, $pageSize)
: $repository->paginateRecharge($filters['store_name'], $filters['from'], $filters['to'], $page, $pageSize),
['rows' => [], 'total' => 0],
);
$summary = $remember(
$scopePrefix . '.summary',
['filters' => $filters],
fn () => $ledger === 'consumption'
? $repository->summaryConsumption($filters['store_name'], $filters['from'], $filters['to'])
: $repository->summaryRecharge($filters['store_name'], $filters['from'], $filters['to']),
['amount_total' => 0, 'balance_total' => 0, 'count' => 0],
);
try {
$optionsResolver = fn () => $repository->shopOptions();
$shopOptions = $repository->usesBusinessMirrorForOptions()
? $optionsResolver()
: $cache->remember('jd-wallet-bills.options', [], $optionsResolver);
} catch (ServiceUnavailableHttpException $exception) {
$errors[] = $exception->getMessage();
$shopOptions = [];
} catch (Throwable $exception) {
report($exception);
$errors[] = '京东账单数据暂时无法读取,请稍后重试。';
$shopOptions = [];
}
return [
'view' => 'banking.marketing_jd_wallet_bills.partials.embedded-table',
'ledger' => $ledger,
'bills' => new LengthAwarePaginator(
Collection::make($result['rows'] ?? []),
(int) ($result['total'] ?? 0),
$pageSize,
$page,
['path' => $request->url(), 'query' => $request->query()],
),
'filters' => $filters,
'summary' => $summary,
'shopOptions' => $shopOptions,
'error' => $errors === []
? null
: implode(' ', array_values(array_unique(array_filter($errors)))),
];
}
return [
'view' => 'banking.marketing_account_bills.partials.embedded-table',
'platform' => $tab,
'accounts' => MarketingAccountBill::platform($tab)
->orderBy('bill_date', 'desc')
->orderBy('id', 'desc')
->paginate(setting('default.list_limit', 25)),
];
}
public function store(Request $request, string $section, string $page, ?string $tab = null)
{
$descriptor = TableRegistry::describe($section, $page, $tab);
abort_if(! $descriptor, 404);
$data = $request->validate($this->validationRules($descriptor));
if ($this->isStoreReceivingAccountPage($section, $page)) {
abort_unless(in_array($tab, ['taobao', 'jd', 'douyin', 'pinduoduo'], true), 404);
$allowedPlatforms = TableRegistry::tabValues($section, $page, $tab);
if (in_array($tab, ['douyin', 'pinduoduo'], true)) {
$data['store_platform'] = $allowedPlatforms[0];
} else {
abort_unless(in_array($data['store_platform'] ?? null, $allowedPlatforms, true), 422, '当前页签不支持该平台类型。');
}
} elseif ($descriptor->tabFilterColumn && $tab && ($tabValue = TableRegistry::tabValue($section, $page, $tab))) {
$data[$descriptor->tabFilterColumn] = $tabValue;
}
foreach (TableRegistry::fixedFilters($section, $page) as $column => $value) {
$data[$column] = $value;
}
($descriptor->model)::create($data);
return $this->redirectBack($section, $page, $tab);
}
public function import(
Request $request,
BusinessTableImporter $importer,
string $section,
string $page,
?string $tab = null,
) {
$descriptor = TableRegistry::describe($section, $page, $tab);
abort_if(! $descriptor, 404);
$request->validate([
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240'],
]);
if ($this->isStoreReceivingAccountPage($section, $page)) {
abort_unless(in_array($tab, ['taobao', 'jd', 'douyin', 'pinduoduo'], true), 404);
$result = app(StoreReceivingAccountWorkbookImporter::class)->import(
$request->file('import'),
$tab,
(int) (company_id() ?: 1),
);
} else {
$result = $importer->import($request->file('import'), $descriptor, $section, $page, $tab);
}
$message = "导入完成:新增 {$result['created']} 条,更新 {$result['updated']} 条。";
if (($result['skipped'] ?? 0) > 0) {
$message .= " 跳过 {$result['skipped']} 条空 ERP 店铺名称示例行。";
}
return $this->redirectBack($section, $page, $tab)->with('success', $message);
}
public function storeReceivingAccountTemplate(string $section, string $page, string $tab)
{
abort_unless($this->isStoreReceivingAccountPage($section, $page), 404);
abort_unless(in_array($tab, ['taobao', 'jd', 'douyin', 'pinduoduo'], true), 404);
return app(StoreReceivingAccountTemplate::class)->download($tab);
}
public function fundForecastTemplate(FundForecastTemplate $template): StreamedResponse
{
return $template->download();
}
public function update(Request $request, string $section, string $page, $id, ?string $tab = null)
{
$descriptor = TableRegistry::describe($section, $page, $tab);
abort_if(! $descriptor, 404);
$row = ($descriptor->model)::findOrFail($id);
$row->update($request->validate($this->validationRules($descriptor)));
return $this->redirectBack($section, $page, $tab);
}
public function destroy(string $section, string $page, $id, ?string $tab = null)
{
$descriptor = TableRegistry::describe($section, $page, $tab);
abort_if(! $descriptor, 404);
($descriptor->model)::findOrFail($id)->delete();
return $this->redirectBack($section, $page, $tab);
}
protected function validationRules($descriptor): array
{
$rules = [];
foreach ($descriptor->fields as $field) {
$rules[$field['name']] = match ($field['type']) {
'number', 'currency' => ['nullable', 'numeric'],
'boolean' => ['nullable', 'boolean'],
'date' => ['nullable', 'date'],
'select' => ['nullable', 'string', 'max:64'],
default => ['nullable', 'string', 'max:255'],
};
}
return $rules;
}
protected function redirectToPage(array $section, array $page)
{
$parameters = [
'section' => $section['slug'],
'page' => $page['slug'],
];
if (! empty($page['tabs'])) {
$parameters['tab'] = $page['tabs'][0]['slug'];
}
return redirect()->route('business.pages.show', $parameters);
}
private function dateFilter(mixed $value): string
{
$date = trim((string) $value);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
return '';
}
[$year, $month, $day] = array_map('intval', explode('-', $date));
return checkdate($month, $day, $year) ? $date : '';
}
private function decimalFilter(mixed $value): ?string
{
$value = trim((string) $value);
if ($value === '' || ! is_numeric($value)) {
return null;
}
return (string) (float) $value;
}
protected function redirectBack(string $section, string $page, ?string $tab)
{
$parameters = [
'company_id' => company_id() ?: 1,
'section' => $section,
'page' => $page,
];
if ($tab) {
$parameters['tab'] = $tab;
}
return redirect()->route('business.pages.show', $parameters)->with('success', true);
}
}