供应商对账:新增厂家账号门户与独立退货视图 (#376)
This commit is contained in:
parent
18d03fe092
commit
a0a6e9285a
@ -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()) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
154
app/Http/Controllers/Portal/SupplierReconciliation.php
Normal file
154
app/Http/Controllers/Portal/SupplierReconciliation.php
Normal 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)];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
298
resources/views/portal/supplier-reconciliation/index.blade.php
Normal file
298
resources/views/portal/supplier-reconciliation/index.blade.php
Normal 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>
|
||||
@ -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" />
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user