供应商对账:同步退货转物流记账归属 (#397)
This commit is contained in:
parent
bf290d5353
commit
c0cc8c7e60
133
modules/EcommerceFinance/Services/SupplierReturnAccounting.php
Normal file
133
modules/EcommerceFinance/Services/SupplierReturnAccounting.php
Normal file
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
/**
|
||||
* 退货记账归属:同一笔退货数据同时供财务端和厂家端展示。
|
||||
*
|
||||
* 备注中出现“记账物流”时,表示该部分不扣厂家、转物流记账;
|
||||
* 当前终版数据由业务先调整厂家净数量/金额,备注保留原始数量及转运数量。
|
||||
*/
|
||||
class SupplierReturnAccounting
|
||||
{
|
||||
/** @param array<string,mixed> $row */
|
||||
public function enrich(array $row): array
|
||||
{
|
||||
$remark = trim((string) ($row['remark'] ?? ''));
|
||||
$title = trim((string) ($row['title'] ?? ''));
|
||||
$isLogistics = preg_match('/记账物流|物流记账|物流.*不记账|不记账.*物流/u', $remark) === 1;
|
||||
$isNoBookkeeping = ! $isLogistics && str_contains($remark, '不记账');
|
||||
$target = $isLogistics ? '物流' : ($isNoBookkeeping ? '不记账' : '厂家');
|
||||
|
||||
$logisticsQty = 0.0;
|
||||
$originalQty = 0.0;
|
||||
if ($isLogistics) {
|
||||
if (preg_match('/\*\s*(\d+(?:\.\d+)?)\s*记账物流/u', $remark, $matches) === 1
|
||||
|| preg_match('/记账物流\s*[×xX*]?\s*(\d+(?:\.\d+)?)/u', $remark, $matches) === 1) {
|
||||
$logisticsQty = (float) $matches[1];
|
||||
}
|
||||
if (preg_match('/原数量\s*(\d+(?:\.\d+)?)/u', $remark, $matches) === 1) {
|
||||
$originalQty = (float) $matches[1];
|
||||
}
|
||||
if ($logisticsQty === 0.0 && $originalQty > 0) {
|
||||
$logisticsQty = max(0.0, $originalQty - $this->number($row['qty'] ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
$qty = $this->number($row['qty'] ?? 0);
|
||||
$unitPrice = $this->number($row['unit_price'] ?? $row['guess_unit_price'] ?? 0);
|
||||
$amount = $this->number($row['amount'] ?? $row['guess_amount'] ?? 0);
|
||||
if ($amount === 0.0 && $qty !== 0.0) {
|
||||
$amount = $qty * $unitPrice;
|
||||
}
|
||||
if ($unitPrice === 0.0 && $qty !== 0.0) {
|
||||
$unitPrice = $amount / $qty;
|
||||
}
|
||||
|
||||
$supplierQty = $qty;
|
||||
$supplierAmount = $amount;
|
||||
$amountContainsLogistics = $originalQty > 0
|
||||
&& $logisticsQty > 0
|
||||
&& abs($amount - ($originalQty * $unitPrice)) < 0.01;
|
||||
if ($amountContainsLogistics) {
|
||||
$supplierQty = max(0.0, $originalQty - $logisticsQty);
|
||||
$supplierAmount = max(0.0, $amount - ($logisticsQty * $unitPrice));
|
||||
}
|
||||
|
||||
$row['_accounting_target'] = $target;
|
||||
$row['_accounting_label'] = $isLogistics ? '转物流记账' : ($isNoBookkeeping ? '不记账' : '厂家扣减');
|
||||
$row['_accounting_note'] = $isLogistics
|
||||
? '不扣本厂家,转' . ($title !== '' ? $title : '物流') . '记账'
|
||||
: ($isNoBookkeeping ? '本厂家不记账' : '按厂家退货扣减');
|
||||
$row['_logistics_qty'] = $isLogistics ? $logisticsQty : 0.0;
|
||||
$row['_logistics_amount'] = $isLogistics
|
||||
? round($logisticsQty * $unitPrice, 2)
|
||||
: 0.0;
|
||||
$row['_logistics_company'] = $isLogistics ? $title : '';
|
||||
$row['_supplier_qty'] = round($supplierQty, 2);
|
||||
$row['_supplier_amount'] = round($supplierAmount, 2);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @return array{count:int,supplier_rows:int,supplier_qty:float,supplier_amount:float,logistics_rows:int,logistics_qty:float,logistics_amount:float,logistics_companies:list<string>,no_bookkeeping_rows:int}
|
||||
*/
|
||||
public function summarize(array $rows): array
|
||||
{
|
||||
$summary = [
|
||||
'count' => count($rows),
|
||||
'supplier_rows' => 0,
|
||||
'supplier_qty' => 0.0,
|
||||
'supplier_amount' => 0.0,
|
||||
'logistics_rows' => 0,
|
||||
'logistics_qty' => 0.0,
|
||||
'logistics_amount' => 0.0,
|
||||
'logistics_companies' => [],
|
||||
'no_bookkeeping_rows' => 0,
|
||||
];
|
||||
$companies = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = $this->enrich($row);
|
||||
$target = (string) $row['_accounting_target'];
|
||||
|
||||
if ($target === '不记账') {
|
||||
$summary['no_bookkeeping_rows']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary['supplier_rows']++;
|
||||
$summary['supplier_qty'] += (float) $row['_supplier_qty'];
|
||||
$summary['supplier_amount'] += (float) $row['_supplier_amount'];
|
||||
|
||||
if ($target !== '物流') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary['logistics_rows']++;
|
||||
$summary['logistics_qty'] += (float) $row['_logistics_qty'];
|
||||
$summary['logistics_amount'] += (float) $row['_logistics_amount'];
|
||||
$company = trim((string) $row['_logistics_company']);
|
||||
if ($company !== '') {
|
||||
$companies[$company] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$summary['supplier_qty'] = round($summary['supplier_qty'], 2);
|
||||
$summary['supplier_amount'] = round($summary['supplier_amount'], 2);
|
||||
$summary['logistics_qty'] = round($summary['logistics_qty'], 2);
|
||||
$summary['logistics_amount'] = round($summary['logistics_amount'], 2);
|
||||
$summary['logistics_companies'] = array_keys($companies);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function number(mixed $value): float
|
||||
{
|
||||
$raw = trim((string) $value);
|
||||
|
||||
return is_numeric(str_replace(',', '', $raw)) ? (float) str_replace(',', '', $raw) : 0.0;
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ namespace Modules\EcommerceFinance\View;
|
||||
use App\Http\Controllers\Business\PayablesFinalActions;
|
||||
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnAccounting;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementStore;
|
||||
@ -25,6 +26,7 @@ class SupplierReconciliationPageData
|
||||
private readonly SupplierStatementMatcher $matcher,
|
||||
private readonly SupplierReturnConfirmation $returnConfirmations,
|
||||
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
|
||||
private readonly SupplierReturnAccounting $returnAccounting,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -85,6 +87,15 @@ class SupplierReconciliationPageData
|
||||
foreach ($this->statements->uploadedSuppliers($companyId) as $name) {
|
||||
$suppliers[$name] = true;
|
||||
}
|
||||
foreach ($this->store->listRows(
|
||||
PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation',
|
||||
$companyId,
|
||||
) as $row) {
|
||||
$name = trim((string) ($row['supplier'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$suppliers[$name] = true;
|
||||
}
|
||||
}
|
||||
$supplierNames = array_keys($suppliers);
|
||||
sort($supplierNames);
|
||||
|
||||
@ -113,6 +124,9 @@ class SupplierReconciliationPageData
|
||||
'reconciler' => $reconciler,
|
||||
'selectedSupplier' => $selected,
|
||||
'detail' => $detail,
|
||||
'financeReturnPanel' => $detail !== null
|
||||
? $this->financeReturnPanel($selected, $companyId, $month, $reconciler)
|
||||
: null,
|
||||
'robot' => $selected !== '' ? $this->dingTalkNotifier->configuration($selected) : null,
|
||||
'listUrl' => route('business.pages.show', array_filter([
|
||||
'section' => 'supplier-reconciliation',
|
||||
@ -144,6 +158,15 @@ class SupplierReconciliationPageData
|
||||
foreach ($this->statements->uploadedSuppliers($companyId) as $name) {
|
||||
$suppliers[$name] = true;
|
||||
}
|
||||
foreach ($this->store->listRows(
|
||||
PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation',
|
||||
$companyId,
|
||||
) as $row) {
|
||||
$name = trim((string) ($row['supplier'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$suppliers[$name] = true;
|
||||
}
|
||||
}
|
||||
$supplierNames = array_keys($suppliers);
|
||||
sort($supplierNames);
|
||||
|
||||
@ -231,6 +254,46 @@ class SupplierReconciliationPageData
|
||||
return $this->detail($mine, $supplier, $companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 财务端比对页的退货面板:与厂家端使用同一终版数据集,并保留月份/对账人筛选。
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function financeReturnPanel(
|
||||
string $supplier,
|
||||
int $companyId,
|
||||
string $month,
|
||||
string $reconciler,
|
||||
): array {
|
||||
$allRows = $this->returnRows($supplier, $companyId);
|
||||
$statuses = $this->returnConfirmations->statuses($supplier, $companyId);
|
||||
$rows = [];
|
||||
|
||||
foreach ($allRows as $row) {
|
||||
$rowMonth = substr(trim((string) ($row['send_time'] ?? '')), 0, 7);
|
||||
if ($rowMonth === '') {
|
||||
$rowMonth = substr(trim((string) ($row['summary_month'] ?? '')), 0, 7);
|
||||
}
|
||||
if ($month !== '' && $rowMonth !== $month) {
|
||||
continue;
|
||||
}
|
||||
if ($reconciler !== '' && trim((string) ($row['reconciler'] ?? '')) !== $reconciler) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row['_status'] = $statuses[(string) ($row['_action_key'] ?? '')] ?? '';
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
return $this->returnConfirmationPanel(
|
||||
$supplier,
|
||||
$rows,
|
||||
$this->returnFilters([]),
|
||||
count($allRows),
|
||||
'finance_return_page',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $input
|
||||
* @return array{returnPanel:array<string,mixed>,returnWarehouses:list<string>}
|
||||
@ -268,7 +331,13 @@ class SupplierReconciliationPageData
|
||||
* @param array{keyword:string,date_from:string,date_to:string,warehouse:string,status:string} $filters
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function returnConfirmationPanel(string $supplier, array $returnRows, array $filters, int $allRows): array
|
||||
private function returnConfirmationPanel(
|
||||
string $supplier,
|
||||
array $returnRows,
|
||||
array $filters,
|
||||
int $allRows,
|
||||
string $pageQueryKey = 'return_page',
|
||||
): array
|
||||
{
|
||||
$total = 0.0;
|
||||
$confirmedAmount = 0.0;
|
||||
@ -278,8 +347,11 @@ class SupplierReconciliationPageData
|
||||
|
||||
$rows = [];
|
||||
foreach ($returnRows as $row) {
|
||||
$row = $this->returnAccounting->enrich($row);
|
||||
$key = (string) ($row['_action_key'] ?? '');
|
||||
$deduction = $this->returnDeduction($row);
|
||||
$deduction = $row['_accounting_target'] === '不记账'
|
||||
? 0.0
|
||||
: (float) $row['_supplier_amount'];
|
||||
$status = (string) ($row['_status'] ?? '');
|
||||
|
||||
$total += $deduction;
|
||||
@ -297,7 +369,7 @@ class SupplierReconciliationPageData
|
||||
// 全部退货行按导入顺序分页展示(每页 15 行),不做金额排序。
|
||||
$perPage = self::PER_PAGE;
|
||||
$pages = max((int) ceil(count($rows) / $perPage), 1);
|
||||
$page = min(max((int) request()->query('return_page', 1), 1), $pages);
|
||||
$page = min(max((int) request()->query($pageQueryKey, 1), 1), $pages);
|
||||
|
||||
$filterQuery = $this->returnFilterQuery($filters);
|
||||
|
||||
@ -314,25 +386,13 @@ class SupplierReconciliationPageData
|
||||
'confirmed_amount' => round($confirmedAmount, 2),
|
||||
'disputed_count' => $disputedCount,
|
||||
'disputed_amount' => round($disputedAmount, 2),
|
||||
'accounting_summary' => $this->returnAccounting->summarize($returnRows),
|
||||
'filters' => $filters,
|
||||
'filter_query' => $filterQuery,
|
||||
'is_filtered' => $filterQuery !== [],
|
||||
];
|
||||
}
|
||||
|
||||
/** 扣减金额:金额为空时用预估金额(与采购方记账口径一致)。 */
|
||||
private function returnDeduction(array $row): float
|
||||
{
|
||||
$amount = trim((string) ($row['amount'] ?? ''));
|
||||
if ($amount !== '' && is_numeric(str_replace(',', '', $amount))) {
|
||||
return (float) str_replace(',', '', $amount);
|
||||
}
|
||||
|
||||
$guess = trim((string) ($row['guess_amount'] ?? ''));
|
||||
|
||||
return is_numeric(str_replace(',', '', $guess)) ? (float) str_replace(',', '', $guess) : 0.0;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function detail(array $mine, string $supplier, int $companyId): array
|
||||
{
|
||||
|
||||
@ -27,8 +27,11 @@
|
||||
.supplier-reconciliation-page .sr-tag.is-extra { background: #FDF1E2; color: #B4650A; }
|
||||
.supplier-reconciliation-page .sr-tag.is-mismatch { background: #FBEBE9; color: #A6392F; }
|
||||
.supplier-reconciliation-page .sr-tag.is-missing { background: #EAF2F9; color: #2F6FA8; }
|
||||
.supplier-reconciliation-page .sr-tag.is-logistics { background: #F7F0DE; color: #856632; }
|
||||
.supplier-reconciliation-page .sr-tag.is-no-bookkeeping { background: #EFEAE0; color: #595959; }
|
||||
.supplier-reconciliation-page tr.is-extra td,
|
||||
.supplier-reconciliation-page tr.is-mismatch td { background: #FFFDF7; }
|
||||
.supplier-reconciliation-page tr.is-logistics td { background: #FFFDF7; }
|
||||
.supplier-reconciliation-page .sr-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
|
||||
.supplier-reconciliation-page .sr-summary-cell { padding: 10px 12px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; display: grid; gap: 4px; }
|
||||
.supplier-reconciliation-page .sr-summary-cell b { font-size: 1.125rem; font-weight: 600; }
|
||||
@ -333,6 +336,110 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@php $returnAccounting = $financeReturnPanel['accounting_summary']; @endphp
|
||||
<div class="sr-card">
|
||||
<div class="sr-toolbar">
|
||||
<h2>退货扣减 · 财务与厂家同步</h2>
|
||||
<span class="sr-muted" style="font-size:.75rem">
|
||||
直接读取「应付款终版 → 退货对账」同一数据集;修改退货数量、备注、标题后,财务端和厂家门户同时更新。
|
||||
</span>
|
||||
</div>
|
||||
<div class="sr-summary">
|
||||
<div class="sr-summary-cell"><span>退货行数</span><b>{{ number_format($returnAccounting['count']) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>厂家净扣减</span><b>¥{{ number_format($returnAccounting['supplier_amount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>转物流记账</span><b>{{ number_format($returnAccounting['logistics_qty'], 2) }} 件</b></div>
|
||||
<div class="sr-summary-cell"><span>转物流金额</span><b>¥{{ number_format($returnAccounting['logistics_amount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>不记账行数</span><b>{{ number_format($returnAccounting['no_bookkeeping_rows']) }}</b></div>
|
||||
</div>
|
||||
@if ($returnAccounting['logistics_rows'] > 0)
|
||||
<p class="sr-hint">
|
||||
已识别 {{ number_format($returnAccounting['logistics_rows']) }} 行“不扣本厂家、转物流记账”
|
||||
@if (! empty($returnAccounting['logistics_companies']))
|
||||
,物流公司:{{ implode('、', $returnAccounting['logistics_companies']) }}
|
||||
@endif
|
||||
。厂家端显示相同备注和“转物流记账”标记,厂家扣减金额按调整后的净数量/金额计算。
|
||||
</p>
|
||||
@endif
|
||||
<div class="sr-scroll">
|
||||
<table class="sr-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">快递单号</th><th scope="col">对账人</th><th scope="col">确认状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($financeReturnPanel['rows'] as $row)
|
||||
<tr @class(['is-logistics' => $row['_accounting_target'] === '物流'])>
|
||||
<td>{{ $row['send_time'] ?? '—' }}</td>
|
||||
<td>{{ $row['barcode'] ?? '—' }}</td>
|
||||
<td>{{ $row['sku_name'] ?? '—' }}</td>
|
||||
<td class="is-num">{{ $row['_accounting_target'] === '不记账' ? '—' : number_format($row['_supplier_qty'], 2) }}</td>
|
||||
<td class="is-num">{{ $row['_accounting_target'] === '不记账' ? '—' : number_format($row['_supplier_amount'], 2) }}</td>
|
||||
<td>
|
||||
<span class="sr-tag {{ $row['_accounting_target'] === '物流' ? 'is-logistics' : ($row['_accounting_target'] === '不记账' ? 'is-no-bookkeeping' : 'is-match') }}">
|
||||
{{ $row['_accounting_label'] }}
|
||||
</span>
|
||||
@if ($row['_accounting_note'] !== '按厂家退货扣减')
|
||||
<div class="sr-muted" style="font-size:.7rem">{{ $row['_accounting_note'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="is-num">{{ $row['_logistics_qty'] > 0 ? number_format($row['_logistics_qty'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['_logistics_amount'] > 0 ? number_format($row['_logistics_amount'], 2) : '—' }}</td>
|
||||
<td>{{ $row['_logistics_company'] !== '' ? $row['_logistics_company'] : '—' }}</td>
|
||||
<td>{{ $row['remark'] ?? '—' }}</td>
|
||||
<td>{{ $row['express_no'] ?? '—' }}</td>
|
||||
<td>{{ $row['reconciler'] ?? '—' }}</td>
|
||||
<td>
|
||||
@if ($row['status'] === 'confirmed')
|
||||
<span class="sr-tag is-match">已确认</span>
|
||||
@elseif ($row['status'] === 'disputed')
|
||||
<span class="sr-tag is-mismatch">异议中</span>
|
||||
@else
|
||||
<span class="sr-muted">待确认</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="13"><div class="sr-empty">当前月份和对账人筛选下没有退货数据。</div></td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if ($financeReturnPanel['pages'] > 1)
|
||||
@php
|
||||
$returnPageCount = $financeReturnPanel['pages'];
|
||||
$returnPageCurrent = $financeReturnPanel['page'];
|
||||
$returnPageStart = max(1, min($returnPageCurrent - 4, $returnPageCount - 8));
|
||||
$returnPageEnd = min($returnPageCount, $returnPageStart + 8);
|
||||
$returnPageUrl = fn (int $number): string => route('business.pages.show', array_filter([
|
||||
'section' => $section['slug'],
|
||||
'page' => $page['slug'],
|
||||
'supplier' => $selectedSupplier,
|
||||
'month' => $month,
|
||||
'reconciler' => $reconciler,
|
||||
'finance_return_page' => $number,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''));
|
||||
@endphp
|
||||
<div class="sr-pagination">
|
||||
<span>第 {{ $returnPageCurrent }} / {{ $returnPageCount }} 页(每页 {{ $financeReturnPanel['per_page'] }} 行)</span>
|
||||
@if ($returnPageCurrent > 1)
|
||||
<a class="sr-button is-small" href="{{ $returnPageUrl($returnPageCurrent - 1) }}">上一页</a>
|
||||
@endif
|
||||
@for ($number = $returnPageStart; $number <= $returnPageEnd; $number++)
|
||||
<a class="sr-button is-small {{ $number === $returnPageCurrent ? 'is-primary' : '' }}"
|
||||
@if ($number === $returnPageCurrent) aria-current="page" @endif
|
||||
href="{{ $returnPageUrl($number) }}">{{ $number }}</a>
|
||||
@endfor
|
||||
@if ($returnPageCurrent < $returnPageCount)
|
||||
<a class="sr-button is-small" href="{{ $returnPageUrl($returnPageCurrent + 1) }}">下一页</a>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
@ -46,6 +46,9 @@
|
||||
.supplier-reconciliation-page .sr-tag { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 4px; font-size: .75rem; white-space: nowrap; }
|
||||
.supplier-reconciliation-page .sr-tag.is-match { background: #ECF4EC; color: #3F7A46; }
|
||||
.supplier-reconciliation-page .sr-tag.is-mismatch { background: #FBEBE9; color: #A6392F; }
|
||||
.supplier-reconciliation-page .sr-tag.is-logistics { margin-left: 4px; background: #F7F0DE; color: #856632; }
|
||||
.supplier-reconciliation-page .sr-tag.is-no-bookkeeping { margin-left: 4px; background: #EFEAE0; color: #595959; }
|
||||
.supplier-reconciliation-page tr.is-logistics td { background: #FFFDF7; }
|
||||
.supplier-reconciliation-page .sr-inline-form { display: inline-flex; }
|
||||
.supplier-reconciliation-page .sr-return-actions { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.supplier-reconciliation-page .sr-return-panel > .sr-return-actions { gap: 12px; }
|
||||
@ -195,6 +198,9 @@
|
||||
<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>
|
||||
@if ($returnPanel['accounting_summary']['logistics_rows'] > 0)
|
||||
<span>· 转物流记账 {{ $returnPanel['accounting_summary']['logistics_rows'] }} 行,{{ number_format($returnPanel['accounting_summary']['logistics_qty'], 2) }} 件(¥{{ number_format($returnPanel['accounting_summary']['logistics_amount'], 2) }})</span>
|
||||
@endif
|
||||
</div>
|
||||
<form method="GET"
|
||||
action="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}"
|
||||
@ -252,7 +258,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($returnPanel['rows'] as $row)
|
||||
<tr>
|
||||
<tr @class(['is-logistics' => $row['_accounting_target'] === '物流'])>
|
||||
<td>{{ $row['send_time'] ?? '—' }}</td>
|
||||
<td>{{ $row['supplier'] ?? '—' }}</td>
|
||||
<td>{{ $row['barcode'] ?? '—' }}</td>
|
||||
@ -261,7 +267,15 @@
|
||||
<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['remark'] ?? '—' }}
|
||||
@if ($row['_accounting_target'] !== '厂家')
|
||||
<span class="sr-tag {{ $row['_accounting_target'] === '物流' ? 'is-logistics' : 'is-no-bookkeeping' }}">{{ $row['_accounting_label'] }}</span>
|
||||
@endif
|
||||
@if ($row['_accounting_note'] !== '按厂家退货扣减')
|
||||
<div class="sr-muted" style="font-size:.7rem">{{ $row['_accounting_note'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $row['title'] ?? '—' }}</td>
|
||||
<td>{{ $row['wms_pick_no'] ?? '—' }}</td>
|
||||
<td>{{ $row['warehouse'] ?? '—' }}</td>
|
||||
|
||||
@ -35,6 +35,9 @@
|
||||
.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-tag.is-logistics { margin-left: 4px; background: #F7F0DE; color: #856632; }
|
||||
.supplier-portal-page .sp-tag.is-no-bookkeeping { margin-left: 4px; background: #EFEAE0; color: #595959; }
|
||||
.supplier-portal-page tr.is-logistics td { background: #FFFDF7; }
|
||||
.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) {
|
||||
@ -95,6 +98,9 @@
|
||||
<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>
|
||||
@if ($returnPanel['accounting_summary']['logistics_rows'] > 0)
|
||||
<span>转物流记账 {{ $returnPanel['accounting_summary']['logistics_rows'] }} 行,{{ number_format($returnPanel['accounting_summary']['logistics_qty'], 2) }} 件(¥{{ number_format($returnPanel['accounting_summary']['logistics_amount'], 2) }})</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<form method="GET"
|
||||
@ -152,7 +158,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($returnPanel['rows'] as $row)
|
||||
<tr>
|
||||
<tr @class(['is-logistics' => $row['_accounting_target'] === '物流'])>
|
||||
<td>{{ $row['send_time'] ?? '—' }}</td>
|
||||
<td>{{ $row['supplier'] ?? '—' }}</td>
|
||||
<td>{{ $row['barcode'] ?? '—' }}</td>
|
||||
@ -161,7 +167,15 @@
|
||||
<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['remark'] ?? '—' }}
|
||||
@if ($row['_accounting_target'] !== '厂家')
|
||||
<span class="sp-tag {{ $row['_accounting_target'] === '物流' ? 'is-logistics' : 'is-no-bookkeeping' }}">{{ $row['_accounting_label'] }}</span>
|
||||
@endif
|
||||
@if ($row['_accounting_note'] !== '按厂家退货扣减')
|
||||
<div class="sp-muted" style="font-size:.7rem">{{ $row['_accounting_note'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $row['title'] ?? '—' }}</td>
|
||||
<td>{{ $row['wms_pick_no'] ?? '—' }}</td>
|
||||
<td>{{ $row['warehouse'] ?? '—' }}</td>
|
||||
|
||||
@ -283,6 +283,79 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
$this->assertCount(0, app(SupplierStatementStore::class)->rows('鸣飞家具', (int) $this->company->id));
|
||||
}
|
||||
|
||||
public function test_return_bookkeeping_note_is_identical_on_finance_and_supplier_views(): void
|
||||
{
|
||||
$companyId = (int) $this->company->id;
|
||||
app(SupplierPayableOverrideStore::class)->save(
|
||||
'final:supplier:payment-reconciliation',
|
||||
'mine:logistics:1',
|
||||
[
|
||||
'summary_month' => '2026-09',
|
||||
'supplier' => '曹县宇诺',
|
||||
'reconciler' => '陈亲园',
|
||||
'skuCode' => 'CZ008407',
|
||||
'qty' => '1',
|
||||
'unitPrice' => '259',
|
||||
'amount' => '259',
|
||||
],
|
||||
$companyId,
|
||||
null,
|
||||
);
|
||||
app(SupplierPayableOverrideStore::class)->save(
|
||||
'final:supplier:return-reconciliation',
|
||||
'return:logistics:1',
|
||||
[
|
||||
'summary_month' => '2026-09',
|
||||
'send_time' => '2026-09-27',
|
||||
'supplier' => '曹县宇诺',
|
||||
'barcode' => 'CZ008407N1178V01',
|
||||
'sku_name' => '款式二 160/80/75 黑胡桃色',
|
||||
'qty' => '1',
|
||||
'unit_price' => '259',
|
||||
'amount' => '259',
|
||||
'remark' => '原数量2件,CZ008407N1178V01*1记账物流',
|
||||
'title' => '常州松宇货运',
|
||||
'express_no' => '202609270079',
|
||||
'reconciler' => '陈亲园',
|
||||
],
|
||||
$companyId,
|
||||
null,
|
||||
);
|
||||
|
||||
$finance = $this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'finance-data',
|
||||
'supplier' => '曹县宇诺',
|
||||
'month' => '2026-09',
|
||||
'reconciler' => '陈亲园',
|
||||
]));
|
||||
$finance->assertOk()
|
||||
->assertSeeText('退货扣减 · 财务与厂家同步')
|
||||
->assertSeeText('转物流记账')
|
||||
->assertSeeText('常州松宇货运')
|
||||
->assertSeeText('原数量2件,CZ008407N1178V01*1记账物流')
|
||||
->assertSeeText('259.00');
|
||||
|
||||
$supplier = $this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'supplier-data',
|
||||
'supplier' => '曹县宇诺',
|
||||
]));
|
||||
$supplier->assertOk()
|
||||
->assertSeeText('转物流记账')
|
||||
->assertSeeText('不扣本厂家,转常州松宇货运记账')
|
||||
->assertSeeText('原数量2件,CZ008407N1178V01*1记账物流');
|
||||
|
||||
$vendor = $this->createVendorPortalUser('曹县宇诺');
|
||||
$this->loginAs($vendor)->get(route('portal.supplier-reconciliation.index', [
|
||||
'company_id' => $companyId,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('转物流记账')
|
||||
->assertSeeText('不扣本厂家,转常州松宇货运记账')
|
||||
->assertSeeText('原数量2件,CZ008407N1178V01*1记账物流');
|
||||
}
|
||||
|
||||
public function test_statement_rows_are_paginated_fifteen_per_page(): void
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
81
tests/Unit/EcommerceFinance/SupplierReturnAccountingTest.php
Normal file
81
tests/Unit/EcommerceFinance/SupplierReturnAccountingTest.php
Normal file
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnAccounting;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SupplierReturnAccountingTest extends TestCase
|
||||
{
|
||||
public function test_logistics_adjustment_keeps_supplier_net_amount_and_tracks_logistics_part(): void
|
||||
{
|
||||
$row = (new SupplierReturnAccounting())->enrich([
|
||||
'qty' => '1',
|
||||
'unit_price' => '259',
|
||||
'amount' => '259',
|
||||
'remark' => '原数量2件,CZ008407N1178V01*1记账物流',
|
||||
'title' => '常州松宇货运',
|
||||
]);
|
||||
|
||||
$this->assertSame('物流', $row['_accounting_target']);
|
||||
$this->assertSame('转物流记账', $row['_accounting_label']);
|
||||
$this->assertSame('不扣本厂家,转常州松宇货运记账', $row['_accounting_note']);
|
||||
$this->assertSame(1.0, $row['_logistics_qty']);
|
||||
$this->assertSame(259.0, $row['_logistics_amount']);
|
||||
$this->assertSame(1.0, $row['_supplier_qty']);
|
||||
$this->assertSame(259.0, $row['_supplier_amount']);
|
||||
}
|
||||
|
||||
public function test_summary_matches_finance_and_supplier_views(): void
|
||||
{
|
||||
$summary = (new SupplierReturnAccounting())->summarize([
|
||||
[
|
||||
'qty' => '1',
|
||||
'unit_price' => '259',
|
||||
'amount' => '259',
|
||||
'remark' => '原数量2件,CZ008407N1178V01*1记账物流',
|
||||
'title' => '常州松宇货运',
|
||||
],
|
||||
[
|
||||
'qty' => '3',
|
||||
'unit_price' => '250',
|
||||
'amount' => '750',
|
||||
'remark' => '原数量4件,CZ008412N1178V01*1记账物流',
|
||||
'title' => '常州松宇货运',
|
||||
],
|
||||
[
|
||||
'qty' => '2',
|
||||
'unit_price' => '30',
|
||||
'amount' => '60',
|
||||
'remark' => '不记账厂家',
|
||||
'title' => '',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame(3, $summary['count']);
|
||||
$this->assertSame(2, $summary['supplier_rows']);
|
||||
$this->assertSame(4.0, $summary['supplier_qty']);
|
||||
$this->assertSame(1009.0, $summary['supplier_amount']);
|
||||
$this->assertSame(2, $summary['logistics_rows']);
|
||||
$this->assertSame(2.0, $summary['logistics_qty']);
|
||||
$this->assertSame(509.0, $summary['logistics_amount']);
|
||||
$this->assertSame(['常州松宇货运'], $summary['logistics_companies']);
|
||||
$this->assertSame(1, $summary['no_bookkeeping_rows']);
|
||||
}
|
||||
|
||||
public function test_full_amount_row_is_reduced_when_quantity_was_not_pre_split(): void
|
||||
{
|
||||
$row = (new SupplierReturnAccounting())->enrich([
|
||||
'qty' => '4',
|
||||
'unit_price' => '250',
|
||||
'amount' => '1000',
|
||||
'remark' => '原数量4件,CZ008412N1178V01*1记账物流',
|
||||
'title' => '常州松宇货运',
|
||||
]);
|
||||
|
||||
$this->assertSame(1.0, $row['_logistics_qty']);
|
||||
$this->assertSame(250.0, $row['_logistics_amount']);
|
||||
$this->assertSame(3.0, $row['_supplier_qty']);
|
||||
$this->assertSame(750.0, $row['_supplier_amount']);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user