fix: 供应商对账识别未定价行 (#1067)

Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
This commit is contained in:
苏木 2026-11-11 13:24:09 +08:00 committed by sumu
parent c32dbe57b2
commit 4e76f76b71
8 changed files with 337 additions and 26 deletions

View File

@ -130,7 +130,7 @@ class SupplierReconciliation extends Controller
$request->validate([
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
'reconciler' => ['nullable', 'string', 'max:191'],
'comparison_filter' => ['nullable', 'in:,supplier_over,supplier_under,mismatch,match'],
'comparison_filter' => ['nullable', 'in:,unpriced,supplier_over,supplier_under,mismatch,match'],
]);
[$supplier, $companyId] = $this->returnTarget($request);
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->query());
@ -232,7 +232,7 @@ class SupplierReconciliation extends Controller
'date' => ['nullable', 'string', 'max:32'],
'barcode' => ['nullable', 'string', 'max:191'],
'difference_type' => ['nullable', 'string', 'max:32'],
'comparison_filter' => ['nullable', 'in:,supplier_over,supplier_under,mismatch,match'],
'comparison_filter' => ['nullable', 'in:,unpriced,supplier_over,supplier_under,mismatch,match'],
'selected_rows' => ['nullable', 'array', 'max:600'],
'selected_rows.*' => ['string', 'max:2048'],
]);
@ -245,7 +245,10 @@ class SupplierReconciliation extends Controller
}
$differences = array_values(array_filter(
$detail['rows'],
static fn (array $row): bool => ($row['type'] ?? '') !== SupplierStatementMatcher::TYPE_MATCH,
static fn (array $row): bool => ! in_array($row['type'] ?? '', [
SupplierStatementMatcher::TYPE_MATCH,
SupplierStatementMatcher::TYPE_UNPRICED,
], true),
));
if ($request->input('scope') === 'row') {

View File

@ -10,7 +10,7 @@ class SupplierComparisonCsvExporter
{
private const HEADERS = [
'采购单号', '日期', '我方商品编码', '厂家条码', '比对结果',
'我方数量', '我方单价', '我方金额',
'我方数量', '我方单价', '我方金额', '我方预估单价', '我方预估金额',
'厂家数量', '厂家单价', '厂家金额',
'数量差异', '金额差异', '结算口径', '结算金额',
];
@ -48,6 +48,8 @@ class SupplierComparisonCsvExporter
'厂家多', (int) ($summary['supplierExtra'] ?? 0),
'数量/金额不一致', (int) ($summary['mismatch'] ?? 0),
'厂家少', (int) ($summary['supplierMissing'] ?? 0),
'未定价', (int) ($summary['unpriced'] ?? 0),
'未定价预估金额', $this->money($summary['unpricedGuessAmount'] ?? 0),
]);
$this->write($csv, [
'我方金额合计', $this->money($summary['mineAmount'] ?? 0),
@ -69,11 +71,15 @@ class SupplierComparisonCsvExporter
$this->number($row['mineQty'] ?? 0),
$this->money($row['mineUnitPrice'] ?? 0),
$this->money($row['mineAmount'] ?? 0),
$this->money($row['mineGuessUnitPrice'] ?? 0),
$this->money($row['mineGuessAmount'] ?? 0),
$this->number($row['theirsQty'] ?? 0),
$this->money($row['theirsUnitPrice'] ?? 0),
$this->money($row['theirsAmount'] ?? 0),
$this->quantityDifference($row['qtyDifference'] ?? 0),
$this->amountDifference($row['amountDifference'] ?? 0),
($row['type'] ?? '') === SupplierStatementMatcher::TYPE_UNPRICED
? '未定价'
: $this->amountDifference($row['amountDifference'] ?? 0),
$row['note'] ?? '',
$this->money($row['settleAmount'] ?? 0),
]);

View File

@ -25,6 +25,8 @@ class SupplierStatementMatcher
public const TYPE_SUPPLIER_MISSING = '厂家少';
public const TYPE_UNPRICED = '未定价';
/** 厂家条码后缀:供应商编号 + 版本,如 N1097V01、N1269V01。 */
private const SUPPLIER_SUFFIX = '/[A-Z]{1,3}\d{3,6}V\d{1,3}$/i';
@ -170,6 +172,8 @@ class SupplierStatementMatcher
$date = $this->normalizeDate($row[$mineSide ? 'date' : 'createdAt'] ?? '');
$expressNo = trim((string) $this->firstValue($row, ['expressNo', 'express_no']));
$unitPrice = (float) ($row['unitPrice'] ?? 0);
$guessUnitPrice = (float) ($row['guessUnitPrice'] ?? $row['guess_unit_price'] ?? 0);
$guessAmount = (float) ($row['guessAmount'] ?? $row['guess_amount'] ?? 0);
$grouped[$key] ??= [
'purchaseNo' => '',
@ -185,6 +189,10 @@ class SupplierStatementMatcher
'expressNos' => [],
'unitPrice' => 0.0,
'unitPrices' => [],
'guessUnitPrice' => 0.0,
'guessUnitPrices' => [],
'guessAmount' => 0.0,
'unpricedQty' => 0.0,
'qty' => 0.0,
'amount' => 0.0,
'sourceRows' => 0,
@ -202,6 +210,9 @@ class SupplierStatementMatcher
if ($unitPrice > 0) {
$grouped[$key]['unitPrices'][number_format($unitPrice, 4, '.', '')] = true;
}
if ($guessUnitPrice > 0) {
$grouped[$key]['guessUnitPrices'][number_format($guessUnitPrice, 4, '.', '')] = true;
}
if ($grouped[$key]['barcode'] === '' && $barcode !== '') {
$grouped[$key]['barcode'] = $barcode;
}
@ -211,6 +222,10 @@ class SupplierStatementMatcher
$grouped[$key]['qty'] += (float) ($row['qty'] ?? 0);
$grouped[$key]['amount'] += (float) ($row['amount'] ?? 0);
$grouped[$key]['guessAmount'] += $guessAmount;
if ($mineSide && $unitPrice <= 0 && (float) ($row['amount'] ?? 0) <= 0) {
$grouped[$key]['unpricedQty'] += (float) ($row['qty'] ?? 0);
}
$grouped[$key]['sourceRows']++;
}
@ -294,6 +309,9 @@ class SupplierStatementMatcher
'supplierExtra' => 0,
'mismatch' => 0,
'supplierMissing' => 0,
'unpriced' => 0,
'unpricedQty' => 0.0,
'unpricedGuessAmount' => 0.0,
'mineAmount' => 0.0,
'theirsAmount' => 0.0,
'supplierOverQty' => 0.0,
@ -313,6 +331,8 @@ class SupplierStatementMatcher
$summary['theirsAmount'] = round((float) $summary['theirsAmount'], 2);
$summary['supplierOverQty'] = round((float) $summary['supplierOverQty'], 4);
$summary['supplierOverAmount'] = round((float) $summary['supplierOverAmount'], 2);
$summary['unpricedQty'] = round((float) $summary['unpricedQty'], 4);
$summary['unpricedGuessAmount'] = round((float) $summary['unpricedGuessAmount'], 2);
$summary['settleQty'] = round((float) $summary['settleQty'], 4);
$summary['settleAmount'] = round((float) $summary['settleAmount'], 2);
$summary['differenceAmount'] = round(
@ -370,11 +390,19 @@ class SupplierStatementMatcher
$unitPrice = $mineUnitPrice > 0 ? $mineUnitPrice : $theirsUnitPrice;
$mineQty = (float) ($mineRow['qty'] ?? 0);
$mineAmount = (float) ($mineRow['amount'] ?? 0);
$mineGuessUnitPrice = (float) ($mineRow['guessUnitPrice'] ?? 0);
$mineGuessAmount = (float) ($mineRow['guessAmount'] ?? 0);
$mineUnpricedQty = (float) ($mineRow['unpricedQty'] ?? 0);
$theirsQty = (float) ($theirsRow['qty'] ?? 0);
$theirsAmount = (float) ($theirsRow['amount'] ?? 0);
$qtyDifference = $theirsQty - $mineQty;
$amountDifference = $theirsAmount - $mineAmount;
$quantityDifference = $this->describeDifference($qtyDifference, $amountDifference);
$mineIsUnpriced = $mineRow !== null
&& $mineUnpricedQty >= $mineQty - 0.005
&& $mineUnitPrice <= 0
&& $mineAmount <= 0;
$theirsIsPriced = $theirsUnitPrice > 0 || $theirsAmount > 0;
$dateDifferenceDays = $this->dateDifferenceDays($date, $theirsDate);
$matchNote = $matchedLayer === null
? ''
@ -401,6 +429,20 @@ class SupplierStatementMatcher
. ',金额 ¥' . number_format($mineAmount, 2)
. ';四层匹配后仍未找到厂家对应记录,按厂家数据结算(不付款)';
$summary['supplierMissing']++;
} elseif ($mineIsUnpriced && $theirsIsPriced && abs($qtyDifference) < 0.005) {
$type = self::TYPE_UNPRICED;
$settleQty = $mineQty;
$settle = 0.0;
$note = $matchNote
. '采购单号、商品编码、数量一致,我方尚未定价'
. ($mineGuessUnitPrice > 0
? '(预估单价 ¥' . number_format($mineGuessUnitPrice, 2)
. ',预估金额 ¥' . number_format($mineGuessAmount, 2) . ')'
: '')
. ';不计厂家多算,待定价后结算';
$summary['unpriced']++;
$summary['unpricedQty'] += $mineQty;
$summary['unpricedGuessAmount'] += $mineGuessAmount;
} elseif (abs($mineAmount - $theirsAmount) < 0.005 && abs($mineQty - $theirsQty) < 0.005) {
$type = self::TYPE_MATCH;
$settleQty = $mineQty;
@ -422,8 +464,10 @@ class SupplierStatementMatcher
$summary['mineAmount'] += $mineAmount;
$summary['theirsAmount'] += $theirsAmount;
$summary['supplierOverQty'] += max($qtyDifference, 0);
$summary['supplierOverAmount'] += max($amountDifference, 0);
if ($type !== self::TYPE_UNPRICED) {
$summary['supplierOverQty'] += max($qtyDifference, 0);
$summary['supplierOverAmount'] += max($amountDifference, 0);
}
$summary['settleQty'] += $settleQty;
$summary['settleAmount'] += $settle;
@ -443,6 +487,8 @@ class SupplierStatementMatcher
'expressNo' => $expressNo,
'unitPrice' => $unitPrice,
'mineUnitPrice' => $mineUnitPrice,
'mineGuessUnitPrice' => $mineGuessUnitPrice,
'mineGuessAmount' => $mineGuessAmount,
'theirsUnitPrice' => $theirsUnitPrice,
'mineQty' => $mineQty,
'mineAmount' => $mineAmount,
@ -451,7 +497,9 @@ class SupplierStatementMatcher
'qtyDifference' => round($qtyDifference, 4),
'amountDifference' => round($amountDifference, 2),
'supplierOverQty' => round(max($qtyDifference, 0), 4),
'supplierOverAmount' => round(max($amountDifference, 0), 2),
'supplierOverAmount' => $type === self::TYPE_UNPRICED
? 0.0
: round(max($amountDifference, 0), 2),
'type' => $type,
'settleQty' => $settleQty,
'settleAmount' => $settle,
@ -527,6 +575,13 @@ class SupplierStatementMatcher
$group['unitPrice'] = round((float) $group['amount'] / (float) $group['qty'], 4);
}
$guessPrices = array_keys((array) ($group['guessUnitPrices'] ?? []));
if (count($guessPrices) === 1) {
$group['guessUnitPrice'] = (float) $guessPrices[0];
} elseif ((float) $group['qty'] != 0.0) {
$group['guessUnitPrice'] = round((float) $group['guessAmount'] / (float) $group['qty'], 4);
}
return $group;
}

View File

@ -13,7 +13,7 @@ use Modules\EcommerceFinance\Services\SupplierStatementStore;
/**
* 「供应商对账」页面数据:
* - 财务端数据:按供应商汇总 + 行级差异(采购单号 + 日期 + 条码匹配,四类结果带结算口径)
* - 财务端数据:按供应商与商品编码汇总,区分一致、未定价、厂家多、厂家少和数量/金额差异
* - 厂家端数据:厂家上传的对账单(实时)与只读的退货明细
*/
class SupplierReconciliationPageData
@ -21,6 +21,7 @@ class SupplierReconciliationPageData
/** 财务端比对结果筛选。 */
private const COMPARISON_FILTERS = [
'',
'unpriced',
'supplier_over',
'supplier_under',
'mismatch',
@ -599,6 +600,9 @@ class SupplierReconciliationPageData
'theirsAmount' => 0.0,
'supplierOverQty' => 0.0,
'supplierOverAmount' => 0.0,
'unpriced' => 0,
'unpricedQty' => 0.0,
'unpricedGuessAmount' => 0.0,
'settleQty' => 0.0,
'settleAmount' => 0.0,
'differenceAmount' => 0.0,
@ -650,6 +654,7 @@ class SupplierReconciliationPageData
$allRows = array_values((array) ($detail['rows'] ?? []));
$counts = [
'all' => count($allRows),
'unpriced' => 0,
'supplier_over' => 0,
'supplier_under' => 0,
'mismatch' => 0,
@ -658,10 +663,13 @@ class SupplierReconciliationPageData
$differenceCount = 0;
foreach ($allRows as $row) {
if ((string) ($row['type'] ?? '') !== SupplierStatementMatcher::TYPE_MATCH) {
if (! in_array((string) ($row['type'] ?? ''), [
SupplierStatementMatcher::TYPE_MATCH,
SupplierStatementMatcher::TYPE_UNPRICED,
], true)) {
$differenceCount++;
}
foreach (['supplier_over', 'supplier_under', 'mismatch', 'match'] as $filter) {
foreach (['unpriced', 'supplier_over', 'supplier_under', 'mismatch', 'match'] as $filter) {
if ($this->comparisonRowMatchesFilter($row, $filter)) {
$counts[$filter]++;
}
@ -674,7 +682,10 @@ class SupplierReconciliationPageData
));
$filteredDifferenceCount = count(array_filter(
$rows,
static fn (array $row): bool => (string) ($row['type'] ?? '') !== SupplierStatementMatcher::TYPE_MATCH,
static fn (array $row): bool => ! in_array((string) ($row['type'] ?? ''), [
SupplierStatementMatcher::TYPE_MATCH,
SupplierStatementMatcher::TYPE_UNPRICED,
], true),
));
$detail['all_total_rows'] = count($allRows);
@ -695,10 +706,17 @@ class SupplierReconciliationPageData
$amountDifference = (float) ($row['amountDifference'] ?? 0);
return match ($comparisonFilter) {
'unpriced' => $type === SupplierStatementMatcher::TYPE_UNPRICED,
'supplier_over' => $type === SupplierStatementMatcher::TYPE_SUPPLIER_EXTRA
|| ($type !== SupplierStatementMatcher::TYPE_MATCH && $amountDifference > 0.005),
|| (
! in_array($type, [SupplierStatementMatcher::TYPE_MATCH, SupplierStatementMatcher::TYPE_UNPRICED], true)
&& $amountDifference > 0.005
),
'supplier_under' => $type === SupplierStatementMatcher::TYPE_SUPPLIER_MISSING
|| ($type !== SupplierStatementMatcher::TYPE_MATCH && $amountDifference < -0.005),
|| (
! in_array($type, [SupplierStatementMatcher::TYPE_MATCH, SupplierStatementMatcher::TYPE_UNPRICED], true)
&& $amountDifference < -0.005
),
'mismatch' => $type === SupplierStatementMatcher::TYPE_MISMATCH,
'match' => $type === SupplierStatementMatcher::TYPE_MATCH,
default => true,
@ -717,7 +735,7 @@ class SupplierReconciliationPageData
}
/**
* @param array{all:int,supplier_over:int,supplier_under:int,mismatch:int,match:int} $counts
* @param array{all:int,unpriced:int,supplier_over:int,supplier_under:int,mismatch:int,match:int} $counts
* @return list<array{value:string,label:string,count:int,description:string}>
*/
private function comparisonFilterOptions(array $counts): array
@ -727,7 +745,13 @@ class SupplierReconciliationPageData
'value' => '',
'label' => '全部结果',
'count' => (int) ($counts['all'] ?? 0),
'description' => '显示双方一致和全部差异行',
'description' => '显示双方一致、未定价和全部差异行',
],
[
'value' => 'unpriced',
'label' => '未定价',
'count' => (int) ($counts['unpriced'] ?? 0),
'description' => '采购单号、商品编码、数量一致,但我方尚无实际单价和金额',
],
[
'value' => 'supplier_over',

View File

@ -45,10 +45,12 @@
.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-unpriced { background: #FFF4D8; color: #866211; }
.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-mismatch td,
.supplier-reconciliation-page tr.is-unpriced td { background: #FFFDF7; }
.supplier-reconciliation-page tr.is-logistics td { background: #FFFDF7; }
.supplier-reconciliation-page .sr-comparison-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; padding: 9px 10px; border: 1px solid #E4DCCB; border-radius: 8px; background: var(--vip-content-accent-soft, #F7F0DE); }
.supplier-reconciliation-page .sr-filter-tabs { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
@ -74,6 +76,8 @@
.supplier-reconciliation-page .sr-push-preview-head span { color: var(--vip-content-muted, #595959); font-size: .75rem; }
.supplier-reconciliation-page .sr-message-preview { min-height: 42px; display: flex; align-items: center; padding: 9px 12px; border: 1px solid #DED3BD; border-left: 4px solid var(--vip-content-accent-dark, #856632); border-radius: 7px; background: #fff; color: var(--vip-content-ink, #171717); font-size: .875rem; font-weight: 600; line-height: 1.6; font-variant-numeric: tabular-nums; }
.supplier-reconciliation-page .sr-push-scope { color: var(--vip-content-muted, #595959); font-size: .75rem; line-height: 1.55; }
.supplier-reconciliation-page .sr-unpriced-note { color: #866211; font-size: .75rem; line-height: 1.55; }
.supplier-reconciliation-page .sr-estimated { color: #866211; font-size: .7rem; white-space: nowrap; }
.supplier-reconciliation-page .sr-push-controls { min-width: 0; display: grid; gap: 8px; align-content: center; }
.supplier-reconciliation-page .sr-push-basis-field { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 9px; }
.supplier-reconciliation-page .sr-push-basis-field > span { color: #5B5343; font-size: .75rem; font-weight: 600; white-space: nowrap; }
@ -191,7 +195,9 @@
页面“双方原始合计”直接按筛选后的原始行汇总,不受匹配分组影响;匹配过程不会删除或改写我方终版货款对账原始行。<br>
数量、金额一致 → 正常结算;厂家多(我方没有)→ 不结算、退回修改;
数量或金额不一致 → 明确列出双方数量、单价、金额及厂家多算部分,取金额低的一方结算;
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家未填采购单号时,仅在同日期+商品编码能唯一匹配我方一条数据时兜底。<br>
厂家少(我方有)→ 按厂家数据结算(不付款);
采购单号、商品编码、数量一致但仅有我方预估价 → 标记“未定价”,不计厂家多算,待定价后结算。
厂家未填采购单号时,仅在同日期+商品编码能唯一匹配我方一条数据时兜底。<br>
厂家登录:在「厂家登录账号管理」新增供应商并勾选可登录,供应商名称必须与对账数据中的厂家名称完全一致;
厂家登录后只能查看自己的对账单上传记录、全部退货明细及导出数据。
</div>
@ -488,7 +494,7 @@
@endphp
<div class="sr-comparison-tools">
<nav class="sr-filter-tabs" aria-label="比对结果筛选">
<span class="sr-filter-caption">差异筛选</span>
<span class="sr-filter-caption">结果筛选</span>
@foreach ($comparisonFilters as $filterOption)
<a href="{{ $comparisonFilterUrl($filterOption['value']) }}"
class="sr-filter-tab {{ $comparisonFilter === $filterOption['value'] ? 'is-active' : '' }}"
@ -518,6 +524,9 @@
: '当前账期';
$settlementQty = (float) ($detail['summary']['settleQty'] ?? 0);
$settlementAmount = (float) ($detail['summary']['settleAmount'] ?? 0);
$unpricedCount = (int) ($detail['summary']['unpriced'] ?? 0);
$unpricedQty = (float) ($detail['summary']['unpricedQty'] ?? 0);
$unpricedGuessAmount = (float) ($detail['summary']['unpricedGuessAmount'] ?? 0);
$mineQty = (float) ($detail['sourceTotals']['mineQty'] ?? 0);
$mineAmount = (float) ($detail['sourceTotals']['mineAmount'] ?? 0);
$theirsQty = (float) ($detail['sourceTotals']['theirsQty'] ?? 0);
@ -533,7 +542,12 @@
<span>实际发送以这里显示的内容为准</span>
</div>
<div id="summary-push-preview" class="sr-message-preview" aria-live="polite">账期:{{ $displayMonthLabel }},件数{{ $formatPushQty($pushPreviewQty) }}件,货款{{ number_format($pushPreviewAmount, 2, '.', '') }}元</div>
<div class="sr-push-scope">汇总范围:当前账期 + 当前对账人;下方“差异筛选”只改变表格显示,不改变整期推送范围。</div>
<div class="sr-push-scope">汇总范围:当前账期 + 当前对账人;下方“结果筛选”只改变表格显示,不改变整期推送范围。</div>
@if ($unpricedCount > 0)
<div class="sr-unpriced-note">
另有 {{ number_format($unpricedCount) }} 条未定价,共 {{ $formatPushQty($unpricedQty) }} 件,预估金额 {{ number_format($unpricedGuessAmount, 2) }} 元;未计价金额不计入推送货款,待定价后结算。
</div>
@endif
</div>
<div class="sr-push-controls">
<label class="sr-push-basis-field" for="summary-push-basis">
@ -582,6 +596,7 @@
<div class="sr-summary-cell"><span>厂家多(退回修改)</span><b>{{ $detail['summary']['supplierExtra'] }}</b></div>
<div class="sr-summary-cell"><span>数量/金额不一致(按低结算)</span><b>{{ $detail['summary']['mismatch'] }}</b></div>
<div class="sr-summary-cell"><span>厂家少(按厂家结算)</span><b>{{ $detail['summary']['supplierMissing'] }}</b></div>
<div class="sr-summary-cell"><span>未定价(预估 ¥{{ number_format($detail['summary']['unpricedGuessAmount'], 2) }})</span><b>{{ $detail['summary']['unpriced'] }}</b></div>
</div>
</section>
<section class="sr-summary-group" aria-label="双方原始合计">
@ -633,12 +648,13 @@
'厂家多' => 'is-extra',
'数量/金额不一致' => 'is-mismatch',
'厂家少' => 'is-missing',
'未定价' => 'is-unpriced',
default => 'is-match',
};
@endphp
<tr class="{{ $tagClass }}">
<td class="sr-select-cell">
@if ($row['type'] !== '一致')
@if (! in_array($row['type'], ['一致', '未定价'], true))
<input class="js-difference-checkbox"
form="push-selected-form"
type="checkbox"
@ -672,8 +688,20 @@
@endif
</td>
<td class="is-num">{{ $row['mineQty'] !== 0.0 ? number_format($row['mineQty'], 2) : '—' }}</td>
<td class="is-num">{{ $row['mineUnitPrice'] !== 0.0 ? number_format($row['mineUnitPrice'], 2) : '—' }}</td>
<td class="is-num">{{ $row['mineAmount'] !== 0.0 ? number_format($row['mineAmount'], 2) : '—' }}</td>
<td class="is-num">
@if ($row['type'] === '未定价' && (float) ($row['mineGuessUnitPrice'] ?? 0) > 0)
<span class="sr-estimated">预估 {{ number_format($row['mineGuessUnitPrice'], 2) }}</span>
@else
{{ $row['mineUnitPrice'] !== 0.0 ? number_format($row['mineUnitPrice'], 2) : '—' }}
@endif
</td>
<td class="is-num">
@if ($row['type'] === '未定价' && (float) ($row['mineGuessAmount'] ?? 0) > 0)
<span class="sr-estimated">预估 {{ number_format($row['mineGuessAmount'], 2) }}</span>
@else
{{ $row['mineAmount'] !== 0.0 ? number_format($row['mineAmount'], 2) : '—' }}
@endif
</td>
<td class="is-num">{{ $row['theirsQty'] !== 0.0 ? number_format($row['theirsQty'], 2) : '—' }}</td>
<td class="is-num">{{ $row['theirsUnitPrice'] !== 0.0 ? number_format($row['theirsUnitPrice'], 2) : '—' }}</td>
<td class="is-num">{{ $row['theirsAmount'] !== 0.0 ? number_format($row['theirsAmount'], 2) : '—' }}</td>
@ -685,7 +713,9 @@
@endif
</td>
<td class="is-num">
@if (abs($row['amountDifference']) < 0.005)
@if ($row['type'] === '未定价')
未定价
@elseif (abs($row['amountDifference']) < 0.005)
一致
@else
厂家{{ $row['amountDifference'] > 0 ? '多' : '少' }} ¥{{ number_format(abs($row['amountDifference']), 2) }}
@ -694,9 +724,11 @@
<td class="sr-comparison-note">
<span class="sr-note-text" title="{{ $row['note'] }}">{{ $row['note'] }}</span>
</td>
<td class="is-num">{{ number_format($row['settleAmount'], 2) }}</td>
<td class="is-num">{{ $row['type'] === '未定价' ? '待定价' : number_format($row['settleAmount'], 2) }}</td>
<td>
@if ($row['type'] !== '一致')
@if ($row['type'] === '未定价')
<span class="sr-muted">待定价</span>
@elseif ($row['type'] !== '一致')
<form method="POST"
action="{{ route('business.supplier-reconciliation.push-differences') }}"
onsubmit="return confirm('确认将这条差异推送到该厂家钉钉群?')">

View File

@ -195,6 +195,53 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
});
}
public function test_unpriced_rows_are_not_pushed_as_supplier_differences(): void
{
app(SupplierDingTalkNotifier::class)->save('未定价厂家', self::WEBHOOK, 'SEC-test-123', false);
app(SupplierPayableOverrideStore::class)->save(
'final:supplier:payment-reconciliation',
'mine:unpriced',
[
'summary_month' => '2026-10',
'purchaseNo' => '948180',
'date' => '2026-10-14',
'supplier' => '未定价厂家',
'skuCode' => 'SZ014916',
'productCode' => 'SZ014916N1109V01',
'qty' => '20',
'unitPrice' => null,
'amount' => null,
'guessUnitPrice' => '195',
'guessAmount' => '3900',
'reconciler' => '陈亲园',
],
(int) $this->company->id,
null,
);
app(SupplierStatementStore::class)->save('未定价厂家', [
array_merge($this->statementRow('SZ014916N1109V01', '186.00', '20', '3720.00'), [
'purchaseNo' => '948180',
'createdAt' => '2026-10-08',
'supplier' => '未定价厂家',
]),
], 'unpriced.csv', (int) $this->company->id, null);
Http::fake();
$this->loginAs()
->post(route('business.supplier-reconciliation.push-differences'), [
'supplier' => '未定价厂家',
'month' => '2026-10',
'reconciler' => '陈亲园',
'scope' => 'all',
])
->assertStatus(302)
->assertSessionHasErrors([
'dingtalk_push' => '当前筛选没有可推送的差异。',
]);
Http::assertNothingSent();
}
public function test_push_reconciliation_summary_rejects_unknown_basis_without_http_call(): void
{
$this->withExceptionHandling();

View File

@ -466,6 +466,110 @@ class SupplierReconciliationTest extends FeatureTestCase
$this->assertSame(-30.0, $rows['NET-B-N1065V01']['amountDifference']);
}
public function test_finance_comparison_marks_missing_mine_price_as_unpriced(): void
{
$companyId = (int) $this->company->id;
$store = app(SupplierPayableOverrideStore::class);
$store->save(
'final:supplier:payment-reconciliation',
'unpriced:948180',
[
'summary_month' => '2026-10',
'purchaseNo' => '948180',
'date' => '2026-10-14',
'supplier' => '木缘居',
'skuCode' => 'SZ014916',
'productCode' => 'SZ014916N1109V01',
'qty' => '20',
'unitPrice' => null,
'amount' => null,
'guessUnitPrice' => '195',
'guessAmount' => '3900',
'reconciler' => '陈亲园',
],
$companyId,
null,
);
$store->save(
'final:supplier:payment-reconciliation',
'priced:948180',
[
'summary_month' => '2026-10',
'purchaseNo' => '948181',
'date' => '2026-10-14',
'supplier' => '木缘居',
'skuCode' => 'SZ014917',
'productCode' => 'SZ014917N1109V01',
'qty' => '10',
'unitPrice' => '100',
'amount' => '1000',
'guessUnitPrice' => '100',
'guessAmount' => '1000',
'reconciler' => '陈亲园',
],
$companyId,
null,
);
$this->seedStatement('木缘居', [
array_merge($this->statementRow('SZ014916N1109V01', '186.00', '20', '3720.00'), [
'purchaseNo' => '948180',
'createdAt' => '2026-10-08',
'supplier' => '木缘居',
]),
array_merge($this->statementRow('SZ014917N1109V01', '100.00', '10', '1000.00'), [
'purchaseNo' => '948181',
'createdAt' => '2026-10-14',
'supplier' => '木缘居',
]),
]);
$detail = app(SupplierReconciliationPageData::class)->financeDetailForFilters(
'木缘居',
$companyId,
['month' => '2026-10', 'reconciler' => '陈亲园'],
);
$unpriced = collect($detail['rows'])->firstWhere('type', SupplierStatementMatcher::TYPE_UNPRICED);
$this->assertSame(1, $detail['summary']['match']);
$this->assertSame(1, $detail['summary']['unpriced']);
$this->assertSame(0, $detail['summary']['supplierExtra']);
$this->assertSame(0, $detail['summary']['supplierMissing']);
$this->assertSame(0, $detail['summary']['mismatch']);
$this->assertSame(0.0, $detail['summary']['supplierOverAmount']);
$this->assertSame(0.0, $detail['summary']['differenceAmount']);
$this->assertSame(1, $detail['comparison_counts']['unpriced']);
$this->assertSame(0, $detail['difference_count']);
$this->assertSame(195.0, $unpriced['mineGuessUnitPrice']);
$this->assertSame(3900.0, $unpriced['mineGuessAmount']);
$filtered = app(SupplierReconciliationPageData::class)->financeDetailForFilters(
'木缘居',
$companyId,
[
'month' => '2026-10',
'reconciler' => '陈亲园',
'comparison_filter' => 'unpriced',
],
);
$this->assertSame(1, $filtered['total_rows']);
$this->assertSame(1, $filtered['comparison_counts']['unpriced']);
$this->assertSame(0, $filtered['difference_count']);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
'supplier' => '木缘居',
'month' => '2026-10',
'reconciler' => '陈亲园',
]))
->assertOk()
->assertSeeText('未定价')
->assertSeeText('预估 195.00')
->assertSeeText('预估 3,900.00')
->assertSeeText('待定价')
->assertSeeText('未计价金额不计入推送货款,待定价后结算');
}
public function test_finance_comparison_matches_crossed_purchase_numbers_by_barcode_and_quantity(): void
{
$companyId = (int) $this->company->id;

View File

@ -125,6 +125,46 @@ class SupplierStatementMatcherTest extends TestCase
$this->assertStringContainsString('采购单号+商品编码匹配', $result['rows'][0]['note']);
}
public function test_matching_quantity_with_missing_actual_mine_price_is_unpriced(): void
{
$mine = [[
'purchaseNo' => '948180',
'date' => '2026-10-14',
'productCode' => 'SZ014916N1109V01',
'unitPrice' => null,
'qty' => '20',
'amount' => null,
'guessUnitPrice' => '195',
'guessAmount' => '3900',
]];
$theirs = [[
'purchaseNo' => '948180',
'createdAt' => '2026-10-08',
'barcode' => 'SZ014916N1109V01',
'unitPrice' => '186',
'qty' => '20',
'amount' => '3720',
]];
$result = app(SupplierStatementMatcher::class)->netByBarcode($mine, $theirs);
$row = $result['rows'][0];
$this->assertSame(SupplierStatementMatcher::TYPE_UNPRICED, $row['type']);
$this->assertSame(20.0, $row['mineQty']);
$this->assertSame(20.0, $row['theirsQty']);
$this->assertSame(0.0, $row['mineAmount']);
$this->assertSame(3720.0, $row['theirsAmount']);
$this->assertSame(195.0, $row['mineGuessUnitPrice']);
$this->assertSame(3900.0, $row['mineGuessAmount']);
$this->assertSame(1, $result['summary']['unpriced']);
$this->assertSame(20.0, $result['summary']['unpricedQty']);
$this->assertSame(3900.0, $result['summary']['unpricedGuessAmount']);
$this->assertSame(0.0, $result['summary']['supplierOverAmount']);
$this->assertSame(20.0, $result['summary']['settleQty']);
$this->assertSame(0.0, $result['summary']['settleAmount']);
$this->assertSame(0.0, $result['summary']['differenceAmount']);
}
public function test_purchase_and_barcode_match_with_unrecognized_supplier_date(): void
{
$mine = [[