diff --git a/app/Http/Controllers/Auth/Login.php b/app/Http/Controllers/Auth/Login.php index 3d43c6ff..5469a756 100644 --- a/app/Http/Controllers/Auth/Login.php +++ b/app/Http/Controllers/Auth/Login.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth; use App\Abstracts\Http\Controller; use App\Http\Requests\Auth\Login as Request; +use App\Models\Common\Contact; use Illuminate\Foundation\Auth\AuthenticatesUsers; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -79,10 +80,14 @@ class Login extends Controller // Redirect to portal if is customer if ($user->isCustomer()) { $path = session('url.intended', ''); + $portalHome = $user->contact instanceof Contact + && $user->contact->type === Contact::VENDOR_TYPE + ? route('portal.supplier-reconciliation.index', ['company_id' => $company->id]) + : route('portal.dashboard', ['company_id' => $company->id]); // Path must start with company id and 'portal' prefix if (!Str::startsWith($path, $company->id . '/portal')) { - $path = route('portal.dashboard', ['company_id' => $company->id]); + $path = $portalHome; } if (! $request->expectsJson()) { diff --git a/app/Http/Controllers/Business/SupplierReconciliation.php b/app/Http/Controllers/Business/SupplierReconciliation.php index ab1c7eff..c8af3f3f 100644 --- a/app/Http/Controllers/Business/SupplierReconciliation.php +++ b/app/Http/Controllers/Business/SupplierReconciliation.php @@ -6,11 +6,11 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; +use Modules\EcommerceFinance\Services\SupplierReturnCsvExporter; use Modules\EcommerceFinance\Services\SupplierReturnConfirmation; use Modules\EcommerceFinance\Services\SupplierStatementStore; use Modules\EcommerceFinance\Services\SupplierStatementTemplate; use Modules\EcommerceFinance\View\SupplierReconciliationPageData; -use RuntimeException; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -24,6 +24,7 @@ class SupplierReconciliation extends Controller private readonly SupplierStatementStore $statements, private readonly SupplierReturnConfirmation $returnConfirmations, private readonly SupplierReconciliationPageData $pageData, + private readonly SupplierReturnCsvExporter $returnCsvExporter, ) { $this->middleware('auth'); $this->middleware('company.identify'); @@ -96,49 +97,8 @@ class SupplierReconciliation extends Controller { [$supplier, $companyId] = $this->returnTarget($request); $rows = $this->pageData->returnRowsForFilters($supplier, $companyId, $request->query()); - $filenameSupplier = preg_replace('/[\\\\\\/:*?"<>|]+/u', '_', $supplier) ?: '厂家'; - $filename = $filenameSupplier . '_退货明细_' . now()->format('Ymd_His') . '.csv'; - return response()->streamDownload(function () use ($rows): void { - $csv = fopen('php://output', 'w'); - if ($csv === false) { - throw new RuntimeException('无法创建导出文件。'); - } - - fwrite($csv, "\xEF\xBB\xBF"); - fputcsv($csv, [ - '发货时间', '明细供应商', '条形码', '商品名称', '发货量', '单价', '金额', - '供应商条码', '备注', '标题', 'wms配货单号', '仓库名称', '快递单号', '类型', '确认状态', - ]); - - foreach ($rows as $row) { - fputcsv($csv, array_map( - fn (mixed $value): string => $this->csvCell($value), - [ - $row['send_time'] ?? '', - $row['supplier'] ?? '', - $row['barcode'] ?? '', - $row['sku_name'] ?? '', - $row['qty'] ?? '', - $row['unit_price'] ?? '', - $row['amount'] ?? '', - $row['supplier_barcode'] ?? '', - $row['remark'] ?? '', - $row['title'] ?? '', - $row['wms_pick_no'] ?? '', - $row['warehouse'] ?? '', - $row['express_no'] ?? '', - '退货', - $this->returnStatusLabel((string) ($row['_status'] ?? '')), - ], - )); - } - - fclose($csv); - }, $filename, [ - 'Content-Type' => 'text/csv; charset=UTF-8', - 'Cache-Control' => 'private, no-store', - ]); + return $this->returnCsvExporter->download($rows, $supplier); } private function markReturn(Request $request, string $status, string $message): RedirectResponse @@ -164,22 +124,4 @@ class SupplierReconciliation extends Controller return [trim((string) $request->input('supplier')), (int) (company_id() ?: 1)]; } - private function returnStatusLabel(string $status): string - { - return match ($status) { - SupplierReturnConfirmation::STATUS_CONFIRMED => '已确认', - SupplierReturnConfirmation::STATUS_DISPUTED => '异议中', - default => '待确认', - }; - } - - private function csvCell(mixed $value): string - { - $text = trim((string) $value); - if (is_numeric(str_replace(',', '', $text))) { - return $text; - } - - return preg_match('/^[=+\-@\t\r]/u', $text) === 1 ? "\t" . $text : $text; - } } diff --git a/app/Http/Controllers/Portal/Dashboard.php b/app/Http/Controllers/Portal/Dashboard.php index 45c460db..e568ad5c 100644 --- a/app/Http/Controllers/Portal/Dashboard.php +++ b/app/Http/Controllers/Portal/Dashboard.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Portal; +use App\Models\Common\Contact; use App\Models\Document\Document; use App\Traits\Charts; use App\Traits\DateTime; @@ -20,6 +21,10 @@ class Dashboard { $contact = user()->contact; + if ($contact instanceof Contact && $contact->type === Contact::VENDOR_TYPE) { + return redirect()->route('portal.supplier-reconciliation.index', ['company_id' => company_id()]); + } + // Redirect user redirect landing page.. if (!$contact) { return redirect(user()->getLandingPageOfUser()); diff --git a/app/Http/Controllers/Portal/SupplierReconciliation.php b/app/Http/Controllers/Portal/SupplierReconciliation.php new file mode 100644 index 00000000..67753d74 --- /dev/null +++ b/app/Http/Controllers/Portal/SupplierReconciliation.php @@ -0,0 +1,154 @@ +supplierContext(); + $data = $this->pageData->portalData($supplier, $companyId); + + return view('portal.supplier-reconciliation.index', array_merge($data, [ + 'contact' => $contact, + 'supplier' => $supplier, + 'statementTemplateUrl' => route('portal.supplier-reconciliation.template'), + 'uploadUrl' => route('portal.supplier-reconciliation.statement.upload'), + 'returnConfirmUrl' => route('portal.supplier-reconciliation.return.confirm'), + 'returnDisputeUrl' => route('portal.supplier-reconciliation.return.dispute'), + 'returnConfirmAllUrl' => route('portal.supplier-reconciliation.return.confirm-all'), + ])); + } + + public function template(): BinaryFileResponse + { + $this->supplierContext(); + $path = $this->template->path(); + abort_unless(is_file($path), 404, '对账单模板不存在。'); + + return response()->download($path, $this->template->filename()); + } + + public function upload(Request $request): JsonResponse|RedirectResponse + { + $request->validate([ + 'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200'], + ]); + [, $supplier, $companyId] = $this->supplierContext(); + + $records = $this->template->parse($request->file('import'), $supplier); + $rows = $this->statements->save( + $supplier, + $records, + (string) $request->file('import')->getClientOriginalName(), + $companyId, + user_id() ? (int) user_id() : null, + ); + $message = "对账单已保存:{$supplier} 共 {$rows} 行。"; + + return $request->expectsJson() + ? response()->json(['message' => $message, 'rows' => $rows]) + : redirect() + ->route('portal.supplier-reconciliation.index', ['company_id' => $companyId]) + ->with('success', $message); + } + + public function confirmReturn(Request $request): RedirectResponse + { + return $this->markReturn($request, SupplierReturnConfirmation::STATUS_CONFIRMED, '已确认该行退货扣减。'); + } + + public function disputeReturn(Request $request): RedirectResponse + { + return $this->markReturn($request, SupplierReturnConfirmation::STATUS_DISPUTED, '已提交异议,复核通过前该行暂不扣减。'); + } + + public function confirmAllReturns(Request $request): RedirectResponse + { + [, $supplier, $companyId] = $this->supplierContext(); + $keys = array_values(array_map( + static fn (array $row): string => (string) ($row['_action_key'] ?? ''), + $this->pageData->returnRowsForFilters($supplier, $companyId, $request->all()), + )); + $confirmed = $this->returnConfirmations->confirmAll( + $supplier, + $keys, + $companyId, + user_id() ? (int) user_id() : null, + ); + + return back()->with('success', "已全部确认:{$confirmed} 行退货扣减生效(异议行保持暂不扣减)。"); + } + + public function exportReturns(Request $request): StreamedResponse + { + [, $supplier, $companyId] = $this->supplierContext(); + $rows = $this->pageData->returnRowsForFilters($supplier, $companyId, $request->query()); + + return $this->returnCsvExporter->download($rows, $supplier); + } + + private function markReturn(Request $request, string $status, string $message): RedirectResponse + { + $request->validate(['key' => ['required', 'string', 'max:191']]); + [, $supplier, $companyId] = $this->supplierContext(); + $key = (string) $request->input('key'); + $owned = collect($this->pageData->returnRows($supplier, $companyId)) + ->contains(static fn (array $row): bool => (string) ($row['_action_key'] ?? '') === $key); + abort_unless($owned, 403, '该退货明细不属于当前厂家账号。'); + + $this->returnConfirmations->mark( + $supplier, + $key, + $status, + $companyId, + user_id() ? (int) user_id() : null, + ); + + return back()->with('success', $message); + } + + /** @return array{0:Contact,1:string,2:int} */ + private function supplierContext(): array + { + $contact = user()->contact; + abort_unless( + $contact instanceof Contact + && $contact->type === Contact::VENDOR_TYPE + && $contact->enabled, + 403, + '当前账号未绑定有效的厂家资料,请联系财务人员。', + ); + + $supplier = trim((string) $contact->name); + abort_if($supplier === '', 403, '当前厂家账号未设置供应商名称,请联系财务人员。'); + + return [$contact, $supplier, (int) (company_id() ?: 1)]; + } +} diff --git a/app/Listeners/Menu/ShowInPortal.php b/app/Listeners/Menu/ShowInPortal.php index bec95d34..3e41b4b9 100644 --- a/app/Listeners/Menu/ShowInPortal.php +++ b/app/Listeners/Menu/ShowInPortal.php @@ -3,6 +3,7 @@ namespace App\Listeners\Menu; use App\Events\Menu\PortalCreated as Event; +use App\Models\Common\Contact; class ShowInPortal { @@ -16,6 +17,19 @@ class ShowInPortal { $user = user(); $menu = $event->menu; + $contact = $user->contact; + + if ($contact instanceof Contact && $contact->type === Contact::VENDOR_TYPE) { + $menu->route( + 'portal.supplier-reconciliation.index', + '供应商对账', + [], + 10, + ['icon' => 'fact_check'], + ); + + return; + } $inactive = false; diff --git a/modules/EcommerceFinance/Services/SupplierReturnCsvExporter.php b/modules/EcommerceFinance/Services/SupplierReturnCsvExporter.php new file mode 100644 index 00000000..d9cd568b --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierReturnCsvExporter.php @@ -0,0 +1,79 @@ +> $rows */ + public function download(array $rows, string $supplier): StreamedResponse + { + $filenameSupplier = preg_replace('/[\\\\\\/:*?"<>|]+/u', '_', $supplier) ?: '厂家'; + $filename = $filenameSupplier . '_退货明细_' . now()->format('Ymd_His') . '.csv'; + + return response()->streamDownload(function () use ($rows): void { + $csv = fopen('php://output', 'w'); + if ($csv === false) { + throw new RuntimeException('无法创建导出文件。'); + } + + fwrite($csv, "\xEF\xBB\xBF"); + fputcsv($csv, self::HEADERS); + + foreach ($rows as $row) { + fputcsv($csv, array_map( + fn (mixed $value): string => $this->csvCell($value), + [ + $row['send_time'] ?? '', + $row['supplier'] ?? '', + $row['barcode'] ?? '', + $row['sku_name'] ?? '', + $row['qty'] ?? '', + $row['unit_price'] ?? '', + $row['amount'] ?? '', + $row['supplier_barcode'] ?? '', + $row['remark'] ?? '', + $row['title'] ?? '', + $row['wms_pick_no'] ?? '', + $row['warehouse'] ?? '', + $row['express_no'] ?? '', + '退货', + $this->returnStatusLabel((string) ($row['_status'] ?? '')), + ], + )); + } + + fclose($csv); + }, $filename, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + + private function returnStatusLabel(string $status): string + { + return match ($status) { + SupplierReturnConfirmation::STATUS_CONFIRMED => '已确认', + SupplierReturnConfirmation::STATUS_DISPUTED => '异议中', + default => '待确认', + }; + } + + private function csvCell(mixed $value): string + { + $text = trim((string) $value); + if (is_numeric(str_replace(',', '', $text))) { + return $text; + } + + return preg_match('/^[=+\-@\t\r]/u', $text) === 1 ? "\t" . $text : $text; + } +} diff --git a/modules/EcommerceFinance/View/SupplierReconciliationPageData.php b/modules/EcommerceFinance/View/SupplierReconciliationPageData.php index 79c8a214..b24c6e5e 100644 --- a/modules/EcommerceFinance/View/SupplierReconciliationPageData.php +++ b/modules/EcommerceFinance/View/SupplierReconciliationPageData.php @@ -151,7 +151,58 @@ class SupplierReconciliationPageData $page = max((int) request()->query('rows_page', 1), 1); $statementRows = $selected !== '' ? $this->statements->rows($selected, $companyId) : []; - $allReturnRows = $selected !== '' ? $this->returnRows($selected, $companyId) : []; + $returnData = $selected !== '' + ? $this->returnPanelData($selected, $companyId, request()->query()) + : ['returnPanel' => null, 'returnWarehouses' => []]; + + return [ + 'view' => 'business.pages.supplier-reconciliation.supplier-data', + 'supplierNames' => $supplierNames, + 'selectedSupplier' => $selected, + 'uploads' => $selected !== '' ? $this->statements->uploads($selected, $companyId) : [], + 'statementRows' => array_slice($statementRows, ($page - 1) * self::PER_PAGE, self::PER_PAGE), + 'statementTotal' => count($statementRows), + 'returnPanel' => $returnData['returnPanel'], + 'returnWarehouses' => $returnData['returnWarehouses'], + 'rowsPage' => $page, + 'perPage' => self::PER_PAGE, + 'statementTemplateUrl' => route('business.supplier-reconciliation.template'), + 'uploadUrl' => route('business.supplier-reconciliation.statement.upload'), + 'returnConfirmUrl' => route('business.supplier-reconciliation.return.confirm'), + 'returnDisputeUrl' => route('business.supplier-reconciliation.return.dispute'), + 'returnConfirmAllUrl' => route('business.supplier-reconciliation.return.confirm-all'), + ]; + } + + /** + * 厂家门户只读取当前登录联系人绑定的供应商数据。 + * + * @return array + */ + public function portalData(string $supplier, int $companyId): array + { + $page = max((int) request()->query('rows_page', 1), 1); + $statementRows = $this->statements->rows($supplier, $companyId); + $returnData = $this->returnPanelData($supplier, $companyId, request()->query()); + + return [ + 'statementRows' => array_slice($statementRows, ($page - 1) * self::PER_PAGE, self::PER_PAGE), + 'statementTotal' => count($statementRows), + 'uploads' => $this->statements->uploads($supplier, $companyId), + 'returnPanel' => $returnData['returnPanel'], + 'returnWarehouses' => $returnData['returnWarehouses'], + 'rowsPage' => $page, + 'perPage' => self::PER_PAGE, + ]; + } + + /** + * @param array $input + * @return array{returnPanel:array,returnWarehouses:list} + */ + private function returnPanelData(string $supplier, int $companyId, array $input): array + { + $allReturnRows = $this->returnRows($supplier, $companyId); $returnWarehouses = []; foreach ($allReturnRows as $row) { $warehouse = $this->filterText($row['warehouse'] ?? ''); @@ -161,34 +212,17 @@ class SupplierReconciliationPageData } $returnWarehouses = array_keys($returnWarehouses); sort($returnWarehouses, SORT_NATURAL | SORT_FLAG_CASE); - $returnFilters = $this->returnFilters(request()->query()); - $returnRows = $selected !== '' - ? $this->filterReturnRows( - $allReturnRows, - $returnFilters, - $this->returnConfirmations->statuses($selected, $companyId), - ) - : []; - $returnPanel = $selected !== '' - ? $this->returnConfirmationPanel($selected, $returnRows, $returnFilters, count($allReturnRows)) - : null; + + $filters = $this->returnFilters($input); + $returnRows = $this->filterReturnRows( + $allReturnRows, + $filters, + $this->returnConfirmations->statuses($supplier, $companyId), + ); return [ - 'view' => 'business.pages.supplier-reconciliation.supplier-data', - 'supplierNames' => $supplierNames, - 'selectedSupplier' => $selected, - 'uploads' => $selected !== '' ? $this->statements->uploads($selected, $companyId) : [], - 'statementRows' => array_slice($statementRows, ($page - 1) * self::PER_PAGE, self::PER_PAGE), - 'statementTotal' => count($statementRows), - 'returnPanel' => $returnPanel, + 'returnPanel' => $this->returnConfirmationPanel($supplier, $returnRows, $filters, count($allReturnRows)), 'returnWarehouses' => $returnWarehouses, - 'rowsPage' => $page, - 'perPage' => self::PER_PAGE, - 'statementTemplateUrl' => route('business.supplier-reconciliation.template'), - 'uploadUrl' => route('business.supplier-reconciliation.statement.upload'), - 'returnConfirmUrl' => route('business.supplier-reconciliation.return.confirm'), - 'returnDisputeUrl' => route('business.supplier-reconciliation.return.dispute'), - 'returnConfirmAllUrl' => route('business.supplier-reconciliation.return.confirm-all'), ]; } diff --git a/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php b/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php index e47ad1f8..9486cb58 100644 --- a/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php +++ b/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php @@ -70,11 +70,16 @@ 下载对账单模板 + @canany(['read-purchases-vendors', 'create-purchases-vendors']) + 厂家登录账号管理 + @endcanany

比对口径:按「条码 + 单价」逐行核对(同一把钥匙的多行先合并数量与金额)。一致 → 正常结算; 厂家多(我方没有)→ 不结算、退回厂家修改账单;金额/数量不一致 → 取金额低的一方结算; - 厂家少(我方有)→ 按厂家数据结算(不付款)。厂家上传的对账单在「厂家端数据」实时可查。 + 厂家少(我方有)→ 按厂家数据结算(不付款)。厂家上传的对账单在「厂家端数据」实时可查。
+ 厂家登录:在「厂家登录账号管理」新增供应商并勾选可登录,供应商名称必须与对账数据中的厂家名称完全一致; + 厂家登录后只能查看自己的对账单上传记录、全部退货明细及导出数据。

diff --git a/resources/views/portal/supplier-reconciliation/index.blade.php b/resources/views/portal/supplier-reconciliation/index.blade.php new file mode 100644 index 00000000..feb62f83 --- /dev/null +++ b/resources/views/portal/supplier-reconciliation/index.blade.php @@ -0,0 +1,298 @@ + + 供应商对账 + + +
+ + + @if (is_string(session('success'))) +
{{ session('success') }}
+ @endif + @if ($errors->has('import')) + + @endif + +
+
+

{{ $supplier }} · 厂家对账

+

当前登录账号只显示本厂家的上传数据和退货数据,其他厂家数据不会展示。

+
+
+ 下载对账单模板 +
+ @csrf + + +
+
+

+ 请在每月 5 号前上传上月对账单。表头名称、顺序和列数必须与模板完全一致;快递名称、快递单号、采购单号可留空, + 单价或金额留空时会按数量自动补齐。 +

+
+ @if (! empty($uploads[0])) + 最近上传:{{ $uploads[0]['uploaded_at'] ?? '—' }} + 文件:{{ $uploads[0]['filename'] ?? '—' }} + 行数:{{ number_format((int) ($uploads[0]['rows'] ?? 0)) }} + @else + 当前还没有上传过对账单。 + @endif + 退货明细:共 {{ number_format($returnPanel['total_rows']) }} 行 +
+
+ +
+

退货明细(全部退货数据)

+

+ 这里展示本厂家的全部退货数据(包含待确认、已确认和异议中),按导入顺序排列。 + @if ($returnPanel['is_filtered']) + 当前筛选出 {{ number_format($returnPanel['total_rows']) }} 行。 + @endif +

+
+ 退货合计 {{ number_format($returnPanel['total_rows']) }} 行 + 扣减合计 ¥{{ number_format($returnPanel['total_amount'], 2) }} + 已确认 {{ $returnPanel['confirmed_count'] }} 行(¥{{ number_format($returnPanel['confirmed_amount'], 2) }}) + 异议 {{ $returnPanel['disputed_count'] }} 行(暂不扣减 ¥{{ number_format($returnPanel['disputed_amount'], 2) }}) + 待确认 {{ max($returnPanel['total_rows'] - $returnPanel['confirmed_count'] - $returnPanel['disputed_count'], 0) }} 行 +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + @forelse ($returnPanel['rows'] as $row) + + + + + + + + + + + + + + + + + + @empty + + @endforelse + +
发货时间明细供应商条形码商品名称发货量单价金额供应商条码备注标题wms配货单号仓库名称快递单号类型操作
{{ $row['send_time'] ?? '—' }}{{ $row['supplier'] ?? '—' }}{{ $row['barcode'] ?? '—' }}{{ $row['sku_name'] ?? '—' }}{{ $row['qty'] ?? '—' }}{{ $row['unit_price'] ?? '—' }}{{ $row['amount'] ?? '—' }}{{ $row['supplier_barcode'] ?? '—' }}{{ $row['remark'] ?? '—' }}{{ $row['title'] ?? '—' }}{{ $row['wms_pick_no'] ?? '—' }}{{ $row['warehouse'] ?? '—' }}{{ $row['express_no'] ?? '—' }}退货 + + @if ($row['status'] === 'confirmed') + 已确认 + @elseif ($row['status'] === 'disputed') + 异议中 + @else +
+ @csrf + + +
+
+ @csrf + + +
+ @endif +
+
没有符合当前查询条件的退货数据。
+
+ + @if ($returnPanel['pages'] > 1) + @php + $pageCount = $returnPanel['pages']; + $pageCurrent = $returnPanel['page']; + $pageStart = max(1, min($pageCurrent - 4, $pageCount - 8)); + $pageEnd = min($pageCount, $pageStart + 8); + $pageUrl = fn (int $number): string => route('portal.supplier-reconciliation.index', array_merge($returnPanel['filter_query'], [ + 'rows_page' => $rowsPage > 1 ? $rowsPage : null, + 'return_page' => $number, + ])); + @endphp +
+ 第 {{ $pageCurrent }} / {{ $pageCount }} 页(每页 {{ $returnPanel['per_page'] }} 行) + @if ($pageCurrent > 1) + 上一页 + @endif + @for ($number = $pageStart; $number <= $pageEnd; $number++) + {{ $number }} + @endfor + @if ($pageCurrent < $pageCount) + 下一页 + @endif +
+ @endif + +
+
+ @csrf + @foreach ($returnPanel['filter_query'] as $filterName => $filterValue) + + @endforeach + +
+ 确认后退货扣减生效;异议行在财务复核前暂不扣减。 +
+
+ +
+

{{ $supplier }} · 我上传的对账数据(共 {{ number_format($statementTotal) }} 行)

+
+ + + + + + + + + + @forelse ($statementRows as $row) + + + + + + + + + + + + + @empty + + @endforelse + +
快递名称快递单号单据编号(采购单号)创建时间供应商条码规格名称数量单价金额
{{ $row['expressName'] ?? '—' }}{{ $row['expressNo'] ?? '—' }}{{ $row['purchaseNo'] ?? '—' }}{{ $row['createdAt'] ?? '—' }}{{ $row['supplier'] ?? '—' }}{{ $row['barcode'] ?? '—' }}{{ $row['skuName'] ?? '—' }}{{ $row['qty'] ?? '—' }}{{ $row['unitPrice'] ?? '—' }}{{ $row['amount'] ?? '—' }}
还没有上传过对账单。
+
+ @if ($statementTotal > $perPage) + @php + $statementPageCount = max((int) ceil($statementTotal / $perPage), 1); + $statementPageStart = max(1, min($rowsPage - 4, $statementPageCount - 8)); + $statementPageEnd = min($statementPageCount, $statementPageStart + 8); + $statementPageUrl = fn (int $number): string => route('portal.supplier-reconciliation.index', array_merge($returnPanel['filter_query'], [ + 'rows_page' => $number, + 'return_page' => $returnPanel['page'] > 1 ? $returnPanel['page'] : null, + ])); + @endphp +
+ 第 {{ $rowsPage }} / {{ $statementPageCount }} 页(每页 {{ $perPage }} 行) + @if ($rowsPage > 1) + 上一页 + @endif + @for ($number = $statementPageStart; $number <= $statementPageEnd; $number++) + {{ $number }} + @endfor + @if ($rowsPage < $statementPageCount) + 下一页 + @endif +
+ @endif +
+
+
+
diff --git a/resources/views/purchases/vendors/create.blade.php b/resources/views/purchases/vendors/create.blade.php index 5621fd56..cc91f7f2 100644 --- a/resources/views/purchases/vendors/create.blade.php +++ b/resources/views/purchases/vendors/create.blade.php @@ -10,7 +10,7 @@ > - + diff --git a/resources/views/purchases/vendors/edit.blade.php b/resources/views/purchases/vendors/edit.blade.php index c78b8268..9430ed24 100644 --- a/resources/views/purchases/vendors/edit.blade.php +++ b/resources/views/purchases/vendors/edit.blade.php @@ -4,7 +4,7 @@ - + diff --git a/routes/portal.php b/routes/portal.php index fff97b6c..9a13fe64 100644 --- a/routes/portal.php +++ b/routes/portal.php @@ -10,6 +10,22 @@ use Illuminate\Support\Facades\Route; */ Route::group(['as' => 'portal.'], function () { + Route::get('supplier-reconciliation', 'Portal\SupplierReconciliation@index') + ->name('supplier-reconciliation.index'); + Route::get('supplier-reconciliation/statement-template', 'Portal\SupplierReconciliation@template') + ->name('supplier-reconciliation.template'); + Route::post('supplier-reconciliation/statement', 'Portal\SupplierReconciliation@upload') + ->middleware('import') + ->name('supplier-reconciliation.statement.upload'); + Route::get('supplier-reconciliation/returns/export', 'Portal\SupplierReconciliation@exportReturns') + ->name('supplier-reconciliation.returns.export'); + Route::post('supplier-reconciliation/returns/confirm', 'Portal\SupplierReconciliation@confirmReturn') + ->name('supplier-reconciliation.return.confirm'); + Route::post('supplier-reconciliation/returns/dispute', 'Portal\SupplierReconciliation@disputeReturn') + ->name('supplier-reconciliation.return.dispute'); + Route::post('supplier-reconciliation/returns/confirm-all', 'Portal\SupplierReconciliation@confirmAllReturns') + ->name('supplier-reconciliation.return.confirm-all'); + Route::get('invoices/{invoice}/print', 'Portal\Invoices@printInvoice')->name('invoices.print'); Route::get('invoices/{invoice}/pdf', 'Portal\Invoices@pdfInvoice')->name('invoices.pdf'); Route::get('invoices/{invoice}/finish', 'Portal\Invoices@finish')->name('invoices.finish'); diff --git a/tests/Feature/Business/SupplierReconciliationTest.php b/tests/Feature/Business/SupplierReconciliationTest.php index 30189ec1..87ef3760 100644 --- a/tests/Feature/Business/SupplierReconciliationTest.php +++ b/tests/Feature/Business/SupplierReconciliationTest.php @@ -2,8 +2,12 @@ namespace Tests\Feature\Business; +use App\Jobs\Common\CreateContact; use App\Models\Auth\Permission; +use App\Models\Auth\User; +use App\Models\Common\Contact; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore; use Modules\EcommerceFinance\Services\SupplierStatementMatcher; @@ -151,6 +155,134 @@ class SupplierReconciliationTest extends FeatureTestCase } } + public function test_vendor_portal_shows_only_all_returns_for_the_logged_in_supplier(): void + { + $store = app(SupplierPayableOverrideStore::class); + $companyId = (int) $this->company->id; + for ($i = 1; $i <= 20; $i++) { + $store->save('final:supplier:return-reconciliation', sprintf('portal:%02d', $i), [ + 'summary_month' => '2026-09', + 'send_time' => '2026-09-20', + 'supplier' => '晨丰', + 'barcode' => sprintf('PORTAL-%02d', $i), + 'sku_name' => '门户退货商品', + 'qty' => '1', + 'unit_price' => '100', + 'amount' => '100', + ], $companyId, null); + } + $store->save('final:supplier:return-reconciliation', 'portal:other', [ + 'summary_month' => '2026-09', + 'send_time' => '2026-09-20', + 'supplier' => '鸣飞家具', + 'barcode' => 'OTHER-SUPPLIER-RETURN', + 'qty' => '1', + 'unit_price' => '100', + 'amount' => '100', + ], $companyId, null); + + $vendor = $this->createVendorPortalUser('晨丰'); + $portalUrl = route('portal.supplier-reconciliation.index', ['company_id' => $companyId]); + + $this->post(route('login'), [ + 'email' => $vendor->email, + 'password' => 'portal-password', + ]) + ->assertRedirect($portalUrl); + + $this->loginAs($vendor)->get($portalUrl) + ->assertOk() + ->assertSeeText('供应商对账') + ->assertSeeText('晨丰 · 厂家对账') + ->assertSeeText('退货明细(全部退货数据)') + ->assertSeeText('PORTAL-01') + ->assertDontSeeText('PORTAL-20') + ->assertDontSeeText('OTHER-SUPPLIER-RETURN') + ->assertDontSeeText('预估单价'); + + $this->loginAs($vendor)->get($portalUrl . '?return_page=2') + ->assertOk() + ->assertSeeText('PORTAL-20') + ->assertDontSeeText('PORTAL-01') + ->assertDontSeeText('OTHER-SUPPLIER-RETURN'); + } + + public function test_non_vendor_portal_account_cannot_open_supplier_return_page(): void + { + $this->withExceptionHandling(); + $customer = Contact::customer()->whereNotNull('user_id')->firstOrFail(); + + $this->loginAs($customer->user) + ->get(route('portal.supplier-reconciliation.index', ['company_id' => $this->company->id])) + ->assertForbidden(); + } + + public function test_vendor_portal_export_and_confirmation_are_scoped_to_the_bound_supplier(): void + { + $this->withExceptionHandling(); + $store = app(SupplierPayableOverrideStore::class); + $companyId = (int) $this->company->id; + $store->save('final:supplier:return-reconciliation', 'portal:own', [ + 'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '晨丰', + 'barcode' => 'OWN-RETURN', 'qty' => '1', 'unit_price' => '100', 'amount' => '100', + ], $companyId, null); + $store->save('final:supplier:return-reconciliation', 'portal:foreign', [ + 'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '鸣飞家具', + 'barcode' => 'FOREIGN-RETURN', 'qty' => '1', 'unit_price' => '100', 'amount' => '100', + ], $companyId, null); + + $vendor = $this->createVendorPortalUser('晨丰'); + $response = $this->loginAs($vendor)->get(route('portal.supplier-reconciliation.returns.export', [ + 'company_id' => $companyId, + 'supplier' => '鸣飞家具', + ])); + $content = $response->streamedContent(); + + $response->assertOk()->assertDownload(); + $this->assertStringContainsString('OWN-RETURN', $content); + $this->assertStringNotContainsString('FOREIGN-RETURN', $content); + $this->assertStringNotContainsString('预估单价', $content); + + $this->loginAs($vendor) + ->post(route('portal.supplier-reconciliation.return.confirm', ['company_id' => $companyId]), [ + 'key' => 'portal:foreign', + ]) + ->assertForbidden(); + + $this->loginAs($vendor) + ->post(route('portal.supplier-reconciliation.return.confirm', ['company_id' => $companyId]), [ + 'key' => 'portal:own', + ]) + ->assertStatus(302) + ->assertSessionHas('success', '已确认该行退货扣减。'); + + $statuses = app(\Modules\EcommerceFinance\Services\SupplierReturnConfirmation::class) + ->statuses('晨丰', $companyId); + $this->assertSame('confirmed', $statuses['portal:own']); + $this->assertArrayNotHasKey('portal:foreign', $statuses); + } + + public function test_vendor_portal_upload_always_uses_the_bound_supplier(): void + { + $vendor = $this->createVendorPortalUser('晨丰'); + $file = UploadedFile::fake()->createWithContent( + 'statement.csv', + "\xEF\xBB\xBF" . self::HEADER . "\n" + . ",,PORTAL-UPLOAD,2026-09-30,晨丰,ZSJ-1,规格甲,2,128.00,256.00\n", + ); + + $this->loginAs($vendor) + ->post(route('portal.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [ + 'supplier' => '鸣飞家具', + 'import' => $file, + ]) + ->assertStatus(302) + ->assertSessionHas('success', '对账单已保存:晨丰 共 1 行。'); + + $this->assertCount(1, app(SupplierStatementStore::class)->rows('晨丰', (int) $this->company->id)); + $this->assertCount(0, app(SupplierStatementStore::class)->rows('鸣飞家具', (int) $this->company->id)); + } + public function test_statement_rows_are_paginated_fifteen_per_page(): void { $rows = []; @@ -572,6 +704,27 @@ class SupplierReconciliationTest extends FeatureTestCase $this->assertSame('30', $records[1]['amount']); } + private function createVendorPortalUser(string $supplier): User + { + $email = Str::lower(Str::random(12)) . '@vendor.test'; + $contact = $this->dispatch(new CreateContact([ + 'type' => Contact::VENDOR_TYPE, + 'name' => $supplier, + 'email' => $email, + 'currency_code' => default_currency(), + 'password' => 'portal-password', + 'password_confirmation' => 'portal-password', + 'company_id' => (int) $this->company->id, + 'enabled' => '1', + 'create_user' => 'true', + 'created_from' => 'core::test', + ])); + + $this->assertInstanceOf(User::class, $contact->user); + + return $contact->user; + } + private function statementRow(string $barcode, string $unitPrice, string $qty, string $amount): array { return [ diff --git a/tests/Feature/Purchases/VendorsTest.php b/tests/Feature/Purchases/VendorsTest.php index 585b6c22..da03fbfc 100644 --- a/tests/Feature/Purchases/VendorsTest.php +++ b/tests/Feature/Purchases/VendorsTest.php @@ -37,7 +37,8 @@ class VendorsTest extends FeatureTestCase $this->loginAs() ->get(route('vendors.create')) ->assertStatus(200) - ->assertSeeText(trans('general.title.new', ['type' => trans_choice('general.vendors', 1)])); + ->assertSeeText(trans('general.title.new', ['type' => trans_choice('general.vendors', 1)])) + ->assertSeeText(trans('customers.can_login')); } public function testItShouldCreateVendor()