供应商对账:新增厂家账号门户与独立退货视图 (#376)

This commit is contained in:
renshen 2026-10-17 12:01:30 +08:00
parent 18d03fe092
commit a0a6e9285a
14 changed files with 798 additions and 92 deletions

View File

@ -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()) {

View File

@ -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;
}
}

View File

@ -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());

View File

@ -0,0 +1,154 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Models\Common\Contact;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\View\View;
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 Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 厂家门户:账号绑定供应商后,仅可查看和操作自己的对账数据。
*/
class SupplierReconciliation extends Controller
{
public function __construct(
private readonly SupplierStatementTemplate $template,
private readonly SupplierStatementStore $statements,
private readonly SupplierReturnConfirmation $returnConfirmations,
private readonly SupplierReconciliationPageData $pageData,
private readonly SupplierReturnCsvExporter $returnCsvExporter,
) {
}
public function index(): View
{
[$contact, $supplier, $companyId] = $this->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)];
}
}

View File

@ -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;

View File

@ -0,0 +1,79 @@
<?php
namespace Modules\EcommerceFinance\Services;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
/** 厂家退货明细 CSV:只输出厂家核对所需字段。 */
class SupplierReturnCsvExporter
{
private const HEADERS = [
'发货时间', '明细供应商', '条形码', '商品名称', '发货量', '单价', '金额',
'供应商条码', '备注', '标题', 'wms配货单号', '仓库名称', '快递单号', '类型', '确认状态',
];
/** @param list<array<string,mixed>> $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;
}
}

View File

@ -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<string,mixed>
*/
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<string,mixed> $input
* @return array{returnPanel:array<string,mixed>,returnWarehouses:list<string>}
*/
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'),
];
}

View File

@ -70,11 +70,16 @@
</div>
<span style="flex:1 1 auto"></span>
<a href="{{ $statementTemplateUrl }}" class="sr-button">下载对账单模板</a>
@canany(['read-purchases-vendors', 'create-purchases-vendors'])
<a href="{{ route('vendors.index') }}" class="sr-button">厂家登录账号管理</a>
@endcanany
</form>
<p class="sr-hint">
比对口径:按「条码 + 单价」逐行核对(同一把钥匙的多行先合并数量与金额)。一致 → 正常结算;
厂家多(我方没有)→ 不结算、退回厂家修改账单;金额/数量不一致 → 取金额低的一方结算;
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家上传的对账单在「厂家端数据」实时可查。
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家上传的对账单在「厂家端数据」实时可查。<br>
厂家登录:在「厂家登录账号管理」新增供应商并勾选可登录,供应商名称必须与对账数据中的厂家名称完全一致;
厂家登录后只能查看自己的对账单上传记录、全部退货明细及导出数据。
</p>
</div>

View File

@ -0,0 +1,298 @@
<x-layouts.portal>
<x-slot name="title">供应商对账</x-slot>
<x-slot name="content">
<div class="supplier-portal-page">
<style>
.supplier-portal-page { display: grid; gap: 16px; color: var(--vip-content-ink, #171717); }
.supplier-portal-page .sp-card { min-width: 0; max-width: 100%; padding: 16px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 12px; background: #fff; display: grid; gap: 12px; }
.supplier-portal-page h2 { margin: 0; font-size: .875rem; font-weight: 600; }
.supplier-portal-page .sp-toolbar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px; }
.supplier-portal-page .sp-field { display: grid; gap: 4px; }
.supplier-portal-page .sp-field span { color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; }
.supplier-portal-page select,
.supplier-portal-page input[type="text"],
.supplier-portal-page input[type="date"] { width: 100%; height: 34px; min-width: 0; padding: 0 10px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; font-size: .8125rem; }
.supplier-portal-page .sp-button { display: inline-flex; height: 34px; align-items: center; justify-content: center; gap: 6px; padding: 0 14px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; color: var(--vip-content-ink, #171717); font-size: .8125rem; font-weight: 600; text-decoration: none; cursor: pointer; }
.supplier-portal-page .sp-button.is-primary { border-color: var(--vip-content-accent-dark, #856632); background: var(--vip-content-accent-dark, #856632); color: #fff; }
.supplier-portal-page .sp-button.is-small { height: 24px; padding: 0 10px; font-size: .75rem; }
.supplier-portal-page .sp-hint { margin: 0; color: var(--vip-content-muted, #595959); font-size: .75rem; line-height: 1.7; }
.supplier-portal-page .sp-scroll { min-width: 0; max-width: 100%; overflow-x: auto; scrollbar-width: thin; }
.supplier-portal-page table { width: 100%; min-width: 720px; border-collapse: collapse; font-size: .8125rem; }
.supplier-portal-page th { padding: 8px 10px; background: var(--vip-content-accent-soft, #F7F0DE); border-bottom: 1px solid var(--vip-border, #D8D2C7); color: #5B5343; font-size: .75rem; font-weight: 600; text-align: left; white-space: nowrap; }
.supplier-portal-page td { padding: 8px 10px; border-bottom: 1px solid #EFEAE0; }
.supplier-portal-page td.is-num { text-align: right; font-variant-numeric: tabular-nums; }
.supplier-portal-page .sp-muted { color: var(--vip-content-muted, #595959); }
.supplier-portal-page .sp-empty { padding: 28px; border: 1px dashed var(--vip-border, #D8D2C7); border-radius: 8px; color: var(--vip-content-muted, #595959); font-size: .8125rem; text-align: center; }
.supplier-portal-page .sp-alert { padding: 10px 12px; border: 1px solid #C9D9C2; border-radius: 8px; background: #F1F7EE; color: #3F6A31; font-size: .8125rem; }
.supplier-portal-page .sp-alert.is-error { border-color: #E8C4C0; background: #FBEBE9; color: #A6392F; }
.supplier-portal-page .sp-upload { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.supplier-portal-page .sp-summary { display: flex; flex-wrap: wrap; gap: 6px 16px; color: var(--vip-content-muted, #595959); font-size: .75rem; }
.supplier-portal-page .sp-return-table { min-width: 1420px; }
.supplier-portal-page .sp-filter { display: grid; grid-template-columns: minmax(220px, 1.6fr) repeat(2, minmax(135px, .8fr)) minmax(150px, 1fr) minmax(125px, .75fr); gap: 10px 12px; align-items: end; padding: 12px; border: 1px solid #EFEAE0; border-radius: 8px; background: #FCFAF5; }
.supplier-portal-page .sp-filter-actions { display: flex; flex-wrap: wrap; gap: 8px; grid-column: 1 / -1; }
.supplier-portal-page .sp-pagination { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--vip-content-muted, #595959); font-size: .8125rem; }
.supplier-portal-page .sp-tag { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 4px; background: #EFEAE0; color: #595959; font-size: .75rem; white-space: nowrap; }
.supplier-portal-page .sp-tag.is-match { background: #ECF4EC; color: #3F7A46; }
.supplier-portal-page .sp-tag.is-mismatch { background: #FBEBE9; color: #A6392F; }
.supplier-portal-page .sp-inline-form { display: inline-flex; }
.supplier-portal-page .sp-actions { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
@media (max-width: 960px) {
.supplier-portal-page .sp-filter { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.supplier-portal-page .sp-filter { grid-template-columns: minmax(0, 1fr); }
}
</style>
@if (is_string(session('success')))
<div class="sp-alert" role="status">{{ session('success') }}</div>
@endif
@if ($errors->has('import'))
<div class="sp-alert is-error" role="alert">{{ $errors->first('import') }}</div>
@endif
<div class="sp-card">
<div>
<h2>{{ $supplier }} · 厂家对账</h2>
<p class="sp-hint">当前登录账号只显示本厂家的上传数据和退货数据,其他厂家数据不会展示。</p>
</div>
<div class="sp-upload">
<a href="{{ $statementTemplateUrl }}" class="sp-button">下载对账单模板</a>
<form method="POST" action="{{ $uploadUrl }}" enctype="multipart/form-data" class="sp-upload">
@csrf
<input type="file" name="import" accept=".xlsx,.xls,.csv" required>
<button type="submit" class="sp-button is-primary">重新上传 {{ $supplier }} 对账单</button>
</form>
</div>
<p class="sp-hint">
请在每月 5 号前上传上月对账单。表头名称、顺序和列数必须与模板完全一致;快递名称、快递单号、采购单号可留空,
单价或金额留空时会按数量自动补齐。
</p>
<div class="sp-summary">
@if (! empty($uploads[0]))
<span>最近上传:{{ $uploads[0]['uploaded_at'] ?? '—' }}</span>
<span>文件:{{ $uploads[0]['filename'] ?? '—' }}</span>
<span>行数:{{ number_format((int) ($uploads[0]['rows'] ?? 0)) }}</span>
@else
<span>当前还没有上传过对账单。</span>
@endif
<span>退货明细:共 {{ number_format($returnPanel['total_rows']) }} 行</span>
</div>
</div>
<div class="sp-card">
<h2>退货明细(全部退货数据)</h2>
<p class="sp-hint">
这里展示本厂家的全部退货数据(包含待确认、已确认和异议中),按导入顺序排列。
@if ($returnPanel['is_filtered'])
当前筛选出 {{ number_format($returnPanel['total_rows']) }} 行。
@endif
</p>
<div class="sp-summary">
<span>退货合计 {{ number_format($returnPanel['total_rows']) }} 行</span>
<span>扣减合计 ¥{{ number_format($returnPanel['total_amount'], 2) }}</span>
<span>已确认 {{ $returnPanel['confirmed_count'] }} 行(¥{{ number_format($returnPanel['confirmed_amount'], 2) }})</span>
<span>异议 {{ $returnPanel['disputed_count'] }} 行(暂不扣减 ¥{{ number_format($returnPanel['disputed_amount'], 2) }})</span>
<span>待确认 {{ max($returnPanel['total_rows'] - $returnPanel['confirmed_count'] - $returnPanel['disputed_count'], 0) }} 行</span>
</div>
<form method="GET"
action="{{ route('portal.supplier-reconciliation.index') }}"
class="sp-filter"
aria-label="查询退货明细">
<label class="sp-field"><span>关键字</span>
<input type="text" name="return_keyword"
value="{{ $returnPanel['filters']['keyword'] }}"
maxlength="100"
placeholder="条形码 / 商品名称 / 供应商条码 / 配货单号 / 快递单号">
</label>
<label class="sp-field"><span>发货日期起</span>
<input type="date" name="return_date_from" value="{{ $returnPanel['filters']['date_from'] }}">
</label>
<label class="sp-field"><span>发货日期止</span>
<input type="date" name="return_date_to" value="{{ $returnPanel['filters']['date_to'] }}">
</label>
<label class="sp-field"><span>仓库名称</span>
<select name="return_warehouse">
<option value="">全部仓库</option>
@foreach ($returnWarehouses as $warehouse)
<option value="{{ $warehouse }}" @selected($returnPanel['filters']['warehouse'] === $warehouse)>{{ $warehouse }}</option>
@endforeach
</select>
</label>
<label class="sp-field"><span>确认状态</span>
<select name="return_status">
<option value="">全部状态</option>
<option value="pending" @selected($returnPanel['filters']['status'] === 'pending')>待确认</option>
<option value="confirmed" @selected($returnPanel['filters']['status'] === 'confirmed')>已确认</option>
<option value="disputed" @selected($returnPanel['filters']['status'] === 'disputed')>异议中</option>
</select>
</label>
<div class="sp-filter-actions">
<button type="submit" class="sp-button is-primary">查询</button>
<a class="sp-button" href="{{ route('portal.supplier-reconciliation.index') }}">重置</a>
<a class="sp-button"
href="{{ route('portal.supplier-reconciliation.returns.export', $returnPanel['filter_query']) }}">
导出当前筛选结果({{ number_format($returnPanel['total_rows']) }} 行)
</a>
</div>
</form>
<div class="sp-scroll">
<table class="sp-return-table">
<thead>
<tr>
<th scope="col">发货时间</th><th scope="col">明细供应商</th><th scope="col">条形码</th>
<th scope="col">商品名称</th><th scope="col">发货量</th><th scope="col">单价</th>
<th scope="col">金额</th><th scope="col">供应商条码</th><th scope="col">备注</th>
<th scope="col">标题</th><th scope="col">wms配货单号</th><th scope="col">仓库名称</th>
<th scope="col">快递单号</th><th scope="col">类型</th><th scope="col">操作</th>
</tr>
</thead>
<tbody>
@forelse ($returnPanel['rows'] as $row)
<tr>
<td>{{ $row['send_time'] ?? '—' }}</td>
<td>{{ $row['supplier'] ?? '—' }}</td>
<td>{{ $row['barcode'] ?? '—' }}</td>
<td>{{ $row['sku_name'] ?? '—' }}</td>
<td class="is-num">{{ $row['qty'] ?? '—' }}</td>
<td class="is-num">{{ $row['unit_price'] ?? '—' }}</td>
<td class="is-num">{{ $row['amount'] ?? '—' }}</td>
<td>{{ $row['supplier_barcode'] ?? '—' }}</td>
<td>{{ $row['remark'] ?? '—' }}</td>
<td>{{ $row['title'] ?? '—' }}</td>
<td>{{ $row['wms_pick_no'] ?? '—' }}</td>
<td>{{ $row['warehouse'] ?? '—' }}</td>
<td>{{ $row['express_no'] ?? '—' }}</td>
<td><span class="sp-tag">退货</span></td>
<td>
<span class="sp-actions">
@if ($row['status'] === 'confirmed')
<span class="sp-tag is-match">已确认</span>
@elseif ($row['status'] === 'disputed')
<span class="sp-tag is-mismatch">异议中</span>
@else
<form method="POST" action="{{ $returnConfirmUrl }}" class="sp-inline-form">
@csrf
<input type="hidden" name="key" value="{{ $row['_key'] }}">
<button type="submit" class="sp-button is-small is-primary">确认</button>
</form>
<form method="POST" action="{{ $returnDisputeUrl }}" class="sp-inline-form">
@csrf
<input type="hidden" name="key" value="{{ $row['_key'] }}">
<button type="submit" class="sp-button is-small">异议</button>
</form>
@endif
</span>
</td>
</tr>
@empty
<tr><td colspan="15"><div class="sp-empty">没有符合当前查询条件的退货数据。</div></td></tr>
@endforelse
</tbody>
</table>
</div>
@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
<div class="sp-pagination">
<span>第 {{ $pageCurrent }} / {{ $pageCount }} 页(每页 {{ $returnPanel['per_page'] }} 行)</span>
@if ($pageCurrent > 1)
<a class="sp-button is-small" href="{{ $pageUrl($pageCurrent - 1) }}">上一页</a>
@endif
@for ($number = $pageStart; $number <= $pageEnd; $number++)
<a class="sp-button is-small {{ $number === $pageCurrent ? 'is-primary' : '' }}"
@if ($number === $pageCurrent) aria-current="page" @endif
href="{{ $pageUrl($number) }}">{{ $number }}</a>
@endfor
@if ($pageCurrent < $pageCount)
<a class="sp-button is-small" href="{{ $pageUrl($pageCurrent + 1) }}">下一页</a>
@endif
</div>
@endif
<div class="sp-toolbar">
<form method="POST" action="{{ $returnConfirmAllUrl }}" class="sp-inline-form">
@csrf
@foreach ($returnPanel['filter_query'] as $filterName => $filterValue)
<input type="hidden" name="{{ $filterName }}" value="{{ $filterValue }}">
@endforeach
<button type="submit" class="sp-button is-primary">
{{ $returnPanel['is_filtered'] ? '全部确认当前筛选' : '全部确认无异议' }}
</button>
</form>
<span class="sp-hint">确认后退货扣减生效;异议行在财务复核前暂不扣减。</span>
</div>
</div>
<div class="sp-card">
<h2>{{ $supplier }} · 我上传的对账数据(共 {{ number_format($statementTotal) }} 行)</h2>
<div class="sp-scroll">
<table>
<thead>
<tr>
<th scope="col">快递名称</th><th scope="col">快递单号</th><th scope="col">单据编号(采购单号)</th>
<th scope="col">创建时间</th><th scope="col">供应商</th><th scope="col">条码</th>
<th scope="col">规格名称</th><th scope="col">数量</th><th scope="col">单价</th><th scope="col">金额</th>
</tr>
</thead>
<tbody>
@forelse ($statementRows as $row)
<tr>
<td class="sp-muted">{{ $row['expressName'] ?? '—' }}</td>
<td class="sp-muted">{{ $row['expressNo'] ?? '—' }}</td>
<td class="sp-muted">{{ $row['purchaseNo'] ?? '—' }}</td>
<td>{{ $row['createdAt'] ?? '—' }}</td>
<td>{{ $row['supplier'] ?? '—' }}</td>
<td>{{ $row['barcode'] ?? '—' }}</td>
<td>{{ $row['skuName'] ?? '—' }}</td>
<td class="is-num">{{ $row['qty'] ?? '—' }}</td>
<td class="is-num">{{ $row['unitPrice'] ?? '—' }}</td>
<td class="is-num">{{ $row['amount'] ?? '—' }}</td>
</tr>
@empty
<tr><td colspan="10"><div class="sp-empty">还没有上传过对账单。</div></td></tr>
@endforelse
</tbody>
</table>
</div>
@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
<div class="sp-pagination">
<span>第 {{ $rowsPage }} / {{ $statementPageCount }} 页(每页 {{ $perPage }} 行)</span>
@if ($rowsPage > 1)
<a class="sp-button is-small" href="{{ $statementPageUrl($rowsPage - 1) }}">上一页</a>
@endif
@for ($number = $statementPageStart; $number <= $statementPageEnd; $number++)
<a class="sp-button is-small {{ $number === $rowsPage ? 'is-primary' : '' }}"
@if ($number === $rowsPage) aria-current="page" @endif
href="{{ $statementPageUrl($number) }}">{{ $number }}</a>
@endfor
@if ($rowsPage < $statementPageCount)
<a class="sp-button is-small" href="{{ $statementPageUrl($rowsPage + 1) }}">下一页</a>
@endif
</div>
@endif
</div>
</div>
</x-slot>
</x-layouts.portal>

View File

@ -10,7 +10,7 @@
></x-slot>
<x-slot name="content">
<x-contacts.form.content type="vendor" hide-can-login />
<x-contacts.form.content type="vendor" />
</x-slot>
<x-contacts.script type="vendor" />

View File

@ -4,7 +4,7 @@
</x-slot>
<x-slot name="content">
<x-contacts.form.content type="vendor" :model="$vendor" hide-can-login />
<x-contacts.form.content type="vendor" :model="$vendor" />
</x-slot>
<x-contacts.script type="vendor" :contact="$vendor" />

View File

@ -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');

View File

@ -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 [

View File

@ -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()