供应商对账:按采购单号日期条码核对数量金额 (#405)

厂家账单改为按采购单号+日期+条码匹配,逐项比较双方数量、单价和金额,页面与钉钉通知明确展示厂家多算或少算。
This commit is contained in:
renshen 2026-10-20 13:44:00 +08:00
parent 6470346392
commit ad8da1264d
9 changed files with 384 additions and 75 deletions

View File

@ -131,9 +131,10 @@ class SupplierReconciliation extends Controller
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
'reconciler' => ['nullable', 'string', 'max:191'],
'scope' => ['required', 'in:all,row,selected'],
'purchase_no' => ['nullable', 'string', 'max:191'],
'date' => ['nullable', 'string', 'max:32'],
'barcode' => ['nullable', 'string', 'max:191'],
'difference_type' => ['nullable', 'string', 'max:32'],
'unit_price' => ['nullable', 'numeric'],
'selected_rows' => ['nullable', 'array', 'max:600'],
'selected_rows.*' => ['string', 'max:2048'],
]);
@ -145,14 +146,16 @@ class SupplierReconciliation extends Controller
));
if ($request->input('scope') === 'row') {
$purchaseNo = trim((string) $request->input('purchase_no'));
$date = trim((string) $request->input('date'));
$barcode = trim((string) $request->input('barcode'));
$type = trim((string) $request->input('difference_type'));
$unitPrice = (float) $request->input('unit_price');
$differences = array_values(array_filter(
$differences,
static fn (array $row): bool => (string) ($row['barcode'] ?? '') === $barcode
static fn (array $row): bool => (string) ($row['purchaseNo'] ?? '') === $purchaseNo
&& (string) ($row['date'] ?? '') === $date
&& (string) ($row['barcode'] ?? '') === $barcode
&& (string) ($row['type'] ?? '') === $type
&& abs((float) ($row['unitPrice'] ?? 0) - $unitPrice) < 0.005,
));
} elseif ($request->input('scope') === 'selected') {
$selected = [];
@ -214,9 +217,10 @@ class SupplierReconciliation extends Controller
private function differenceRowKey(array $row): string
{
return implode("\0", [
trim((string) ($row['purchaseNo'] ?? $row['purchase_no'] ?? '')),
trim((string) ($row['date'] ?? '')),
trim((string) ($row['barcode'] ?? '')),
trim((string) ($row['type'] ?? '')),
number_format((float) ($row['unitPrice'] ?? 0), 4, '.', ''),
]);
}

View File

@ -165,7 +165,7 @@ class SupplierDingTalkNotifier
'- 对账月份:' . ($month !== '' ? $month : '全部月份'),
'- 对账人:' . ($reconciler !== '' ? $reconciler : '全部'),
'- 差异共 **' . $summary['count'] . '** 条(厂家多 ' . $summary['extra']
. ' / 金额数量不一致 ' . $summary['mismatch'] . ' / 厂家少 ' . $summary['missing'] . ')',
. ' / 数量金额不一致 ' . $summary['mismatch'] . ' / 厂家少 ' . $summary['missing'] . ')',
'- 差异金额:**¥' . number_format($summary['difference_amount'], 2) . '**',
];
@ -179,22 +179,29 @@ class SupplierDingTalkNotifier
$theirsQty = (float) ($row['theirsQty'] ?? 0);
$mineAmount = (float) ($row['mineAmount'] ?? 0);
$theirsAmount = (float) ($row['theirsAmount'] ?? 0);
$mineUnitPrice = (float) ($row['mineUnitPrice'] ?? $row['unitPrice'] ?? 0);
$theirsUnitPrice = (float) ($row['theirsUnitPrice'] ?? $row['unitPrice'] ?? 0);
$purchaseNo = trim((string) ($row['purchaseNo'] ?? ''));
$date = trim((string) ($row['date'] ?? ''));
$barcode = trim((string) ($row['barcode'] ?? ''));
$supplierBarcode = trim((string) ($row['supplierBarcode'] ?? ''));
$lines[] = sprintf(
'%d. **%s** `%s`%s 单价 ¥%s|我方 %s / ¥%s|厂家 %s / ¥%s|%s',
'%d. **%s**|采购单号:%s|日期:%s|条码:%s%s',
$rowOffset + $index + 1,
(string) ($row['type'] ?? '差异'),
$purchaseNo !== '' ? $purchaseNo : '—',
$date !== '' ? $date : '—',
$barcode,
$supplierBarcode !== '' ? '(厂家条码 ' . $supplierBarcode . ')' : '',
number_format((float) ($row['unitPrice'] ?? 0), 2),
$this->number($mineQty),
number_format($mineAmount, 2),
$this->number($theirsQty),
number_format($theirsAmount, 2),
(string) ($row['note'] ?? ''),
);
$lines[] = ' 我方:数量 ' . $this->number($mineQty)
. '|单价 ¥' . number_format($mineUnitPrice, 2)
. '|金额 ¥' . number_format($mineAmount, 2);
$lines[] = ' 厂家:数量 ' . $this->number($theirsQty)
. '|单价 ¥' . number_format($theirsUnitPrice, 2)
. '|金额 ¥' . number_format($theirsAmount, 2);
$lines[] = ' 处理:' . (string) ($row['note'] ?? '');
}
$lines[] = '';

View File

@ -4,9 +4,9 @@ namespace Modules\EcommerceFinance\Services;
/**
* 厂家对账单 vs 我方结算数据:
* - 行键 = 条码(去掉厂家编号/版本后缀,如 CJ019312N1097V01 → CJ019312)+ 单价
* (同一把钥匙的多行先合并:数量、金额相加);
* - 四类结果:一致 / 厂家多(不结算、退回)/ 金额不一致(按金额低的一方结算)/ 厂家少(按厂家数据结算,即不付款)。
* - 行键 = 采购单号 + 日期 + 条码(条码去掉厂家编号/版本后缀);
* - 同一行键的多行先合并数量与金额,再比较双方数量和金额;
* - 厂家未填采购单号时,仅在同日期+条码能唯一对应我方一条数据时兜底匹配。
*/
class SupplierStatementMatcher
{
@ -14,7 +14,7 @@ class SupplierStatementMatcher
public const TYPE_SUPPLIER_EXTRA = '厂家多';
public const TYPE_MISMATCH = '金额不一致';
public const TYPE_MISMATCH = '数量/金额不一致';
public const TYPE_SUPPLIER_MISSING = '厂家少';
@ -22,74 +22,129 @@ class SupplierStatementMatcher
private const SUPPLIER_SUFFIX = '/[A-Z]{1,3}\d{3,6}V\d{1,3}$/i';
/**
* @param list<array<string,mixed>> $mine 我方结算数据(货款对账:skuCode/unitPrice/qty/amount)
* @param list<array<string,mixed>> $theirs 厂家对账单(barcode/unitPrice/qty/amount)
* @param list<array<string,mixed>> $mine 我方数据(purchaseNo/date/skuCode/unitPrice/qty/amount)
* @param list<array<string,mixed>> $theirs 厂家数据(purchaseNo/createdAt/barcode/unitPrice/qty/amount)
* @return array{rows:list<array<string,mixed>>,summary:array<string,mixed>}
*/
public function compare(array $mine, array $theirs): array
{
$mineGrouped = $this->group($mine, 'skuCode');
$theirsGrouped = $this->group($theirs, 'barcode');
$mineGrouped = $this->group($mine, 'skuCode', 'purchaseNo', 'date');
$theirsGrouped = $this->group($theirs, 'barcode', 'purchaseNo', 'createdAt');
// 已有采购单号时严格按三元组匹配;缺失采购单号时,仅在日期+条码唯一时兜底。
$fallbackIndex = [];
foreach ($mineGrouped as $key => $row) {
$fallbackIndex[$row['date'] . "\0" . $row['barcodeKey']][] = $key;
}
$theirsGrouped = $this->resolveMissingPurchaseNumbers($theirsGrouped, $fallbackIndex);
$rows = [];
$summary = ['match' => 0, 'supplierExtra' => 0, 'mismatch' => 0, 'supplierMissing' => 0,
'mineAmount' => 0.0, 'settleAmount' => 0.0];
$summary = [
'match' => 0,
'supplierExtra' => 0,
'mismatch' => 0,
'supplierMissing' => 0,
'mineAmount' => 0.0,
'theirsAmount' => 0.0,
'supplierOverQty' => 0.0,
'supplierOverAmount' => 0.0,
'settleAmount' => 0.0,
];
foreach (array_unique(array_merge(array_keys($mineGrouped), array_keys($theirsGrouped))) as $key) {
$mineRow = $mineGrouped[$key] ?? null;
$theirsRow = $theirsGrouped[$key] ?? null;
$barcode = (string) ($mineRow['barcode'] ?? $theirsRow['barcode']);
$supplierBarcode = (string) ($theirsRow['barcode'] ?? '');
$unitPrice = (float) ($mineRow['unitPrice'] ?? $theirsRow['unitPrice']);
$purchaseNo = (string) ($mineRow['purchaseNo'] ?? $theirsRow['purchaseNo'] ?? '');
$date = (string) ($mineRow['date'] ?? $theirsRow['date'] ?? '');
$barcode = (string) ($mineRow['barcode'] ?? $theirsRow['barcode'] ?? '');
$supplierBarcode = (string) ($theirsRow['supplierBarcode'] ?? '');
$mineUnitPrice = (float) ($mineRow['unitPrice'] ?? 0);
$theirsUnitPrice = (float) ($theirsRow['unitPrice'] ?? 0);
$unitPrice = $mineUnitPrice > 0 ? $mineUnitPrice : $theirsUnitPrice;
$mineQty = (float) ($mineRow['qty'] ?? 0);
$mineAmount = (float) ($mineRow['amount'] ?? 0);
$theirsQty = (float) ($theirsRow['qty'] ?? 0);
$theirsAmount = (float) ($theirsRow['amount'] ?? 0);
$qtyDifference = $theirsQty - $mineQty;
$amountDifference = $theirsAmount - $mineAmount;
$quantityDifference = $this->describeDifference($qtyDifference, $amountDifference);
$fallbackNote = '';
if (($theirsRow['matchedByFallback'] ?? false) === true) {
$fallbackNote = '厂家未填采购单号,已按日期+条码唯一匹配;';
} elseif (($theirsRow['fallbackAmbiguous'] ?? false) === true) {
$fallbackNote = '厂家未填采购单号,且同日期+条码有多条我方数据,无法唯一匹配;';
}
if ($mineRow === null) {
$type = self::TYPE_SUPPLIER_EXTRA;
$settle = 0.0;
$note = '我方没有这条数据:不结算,退回厂家修改账单';
$note = $fallbackNote . '厂家整条多报:' . $quantityDifference . ';我方没有该采购单号+日期+条码,不结算';
$summary['supplierExtra']++;
} elseif ($theirsRow === null) {
$type = self::TYPE_SUPPLIER_MISSING;
$settle = 0.0;
$note = '厂家少报了这条数据:按厂家数据结算(不付款)';
$note = '厂家漏报:我方数量 ' . $this->number($mineQty)
. ',单价 ¥' . number_format($mineUnitPrice, 2)
. ',金额 ¥' . number_format($mineAmount, 2)
. ';按厂家数据结算(不付款)';
$summary['supplierMissing']++;
} elseif (abs($mineAmount - $theirsAmount) < 0.005 && abs($mineQty - $theirsQty) < 0.005) {
$type = self::TYPE_MATCH;
$settle = $mineAmount;
$note = '正常结算';
$note = $fallbackNote . '采购单号、日期、条码、数量和金额一致,正常结算';
$summary['match']++;
} else {
$type = self::TYPE_MISMATCH;
$settle = min($mineAmount, $theirsAmount);
$note = '金额/数量不一致:按金额低的一方结算';
$note = $fallbackNote . $quantityDifference . ';按金额低的一方结算,请厂家修改';
$summary['mismatch']++;
}
$summary['mineAmount'] += $mineAmount;
$summary['theirsAmount'] += $theirsAmount;
$summary['supplierOverQty'] += max($qtyDifference, 0);
$summary['supplierOverAmount'] += max($amountDifference, 0);
$summary['settleAmount'] += $settle;
$rows[] = [
'purchaseNo' => $purchaseNo,
'date' => $date,
'barcode' => $barcode,
'supplierBarcode' => $supplierBarcode === $barcode ? '' : $supplierBarcode,
'supplierBarcode' => $supplierBarcode !== '' && $supplierBarcode !== $barcode ? $supplierBarcode : '',
'unitPrice' => $unitPrice,
'mineUnitPrice' => $mineUnitPrice,
'theirsUnitPrice' => $theirsUnitPrice,
'mineQty' => $mineQty,
'mineAmount' => $mineAmount,
'theirsQty' => $theirsQty,
'theirsAmount' => $theirsAmount,
'qtyDifference' => round($qtyDifference, 4),
'amountDifference' => round($amountDifference, 2),
'supplierOverQty' => round(max($qtyDifference, 0), 4),
'supplierOverAmount' => round(max($amountDifference, 0), 2),
'type' => $type,
'settleAmount' => $settle,
'note' => $note,
];
}
usort($rows, static fn (array $a, array $b): int => [$a['type'] === self::TYPE_MATCH ? 1 : 0, $a['barcode']]
<=> [$b['type'] === self::TYPE_MATCH ? 1 : 0, $b['barcode']]);
usort($rows, static fn (array $a, array $b): int => [
$a['type'] === self::TYPE_MATCH ? 1 : 0,
$a['date'],
$a['purchaseNo'],
$a['barcode'],
] <=> [
$b['type'] === self::TYPE_MATCH ? 1 : 0,
$b['date'],
$b['purchaseNo'],
$b['barcode'],
]);
$summary['mineAmount'] = round($summary['mineAmount'], 2);
$summary['theirsAmount'] = round($summary['theirsAmount'], 2);
$summary['supplierOverQty'] = round($summary['supplierOverQty'], 4);
$summary['supplierOverAmount'] = round($summary['supplierOverAmount'], 2);
$summary['settleAmount'] = round($summary['settleAmount'], 2);
$summary['differenceAmount'] = round($summary['mineAmount'] - $summary['settleAmount'], 2);
@ -97,12 +152,12 @@ class SupplierStatementMatcher
}
/**
* 按「条码 + 单价」归组,数量与金额相加。
* 按「采购单号 + 日期 + 条码」归组,数量与金额相加。
*
* @param list<array<string,mixed>> $rows
* @return array<string,array{barcode:string,unitPrice:float,qty:float,amount:float}>
* @return array<string,array<string,mixed>>
*/
private function group(array $rows, string $barcodeField): array
private function group(array $rows, string $barcodeField, string $purchaseField, string $dateField): array
{
$grouped = [];
foreach ($rows as $row) {
@ -111,22 +166,148 @@ class SupplierStatementMatcher
continue;
}
$barcodeKey = $this->normalizeBarcode($barcode);
$purchaseNo = $this->normalizePurchaseNo($row[$purchaseField] ?? '');
$date = $this->normalizeDate($row[$dateField] ?? '');
$unitPrice = (float) ($row['unitPrice'] ?? 0);
$key = $this->normalizeBarcode($barcode) . '|' . number_format($unitPrice, 4, '.', '');
$key = $this->matchKey($purchaseNo, $date, $barcodeKey);
$grouped[$key] ??= ['barcode' => $barcode, 'unitPrice' => $unitPrice, 'qty' => 0.0, 'amount' => 0.0];
$grouped[$key] ??= [
'purchaseNo' => $purchaseNo,
'date' => $date,
'barcode' => $barcodeKey,
'barcodeKey' => $barcodeKey,
'supplierBarcode' => $barcodeField === 'barcode' ? $barcode : '',
'unitPrice' => $unitPrice,
'unitPrices' => [],
'qty' => 0.0,
'amount' => 0.0,
'matchedByFallback' => false,
'fallbackAmbiguous' => false,
];
if ($unitPrice > 0) {
$grouped[$key]['unitPrices'][number_format($unitPrice, 4, '.', '')] = true;
}
$grouped[$key]['qty'] += (float) ($row['qty'] ?? 0);
$grouped[$key]['amount'] += (float) ($row['amount'] ?? 0);
}
foreach ($grouped as &$group) {
$group = $this->finalizeGroup($group);
}
unset($group);
return $grouped;
}
/**
* @param array<string,array<string,mixed>> $groups
* @param array<string,list<string>> $fallbackIndex
* @return array<string,array<string,mixed>>
*/
private function resolveMissingPurchaseNumbers(array $groups, array $fallbackIndex): array
{
$resolved = [];
foreach ($groups as $key => $group) {
$targetKey = $key;
if ($group['purchaseNo'] === '') {
$candidates = $fallbackIndex[$group['date'] . "\0" . $group['barcodeKey']] ?? [];
if (count($candidates) === 1) {
$targetKey = $candidates[0];
$group['matchedByFallback'] = true;
} elseif (count($candidates) > 1) {
$group['fallbackAmbiguous'] = true;
}
}
if (isset($resolved[$targetKey])) {
$resolved[$targetKey] = $this->mergeGroup($resolved[$targetKey], $group);
} else {
$resolved[$targetKey] = $group;
}
}
return $resolved;
}
/** @param array<string,mixed> $left @param array<string,mixed> $right */
private function mergeGroup(array $left, array $right): array
{
$left['qty'] += (float) $right['qty'];
$left['amount'] += (float) $right['amount'];
$left['unitPrices'] += (array) $right['unitPrices'];
$left['matchedByFallback'] = (bool) $left['matchedByFallback'] || (bool) $right['matchedByFallback'];
$left['fallbackAmbiguous'] = (bool) $left['fallbackAmbiguous'] || (bool) $right['fallbackAmbiguous'];
if ($left['supplierBarcode'] === '' && $right['supplierBarcode'] !== '') {
$left['supplierBarcode'] = $right['supplierBarcode'];
}
return $this->finalizeGroup($left);
}
/** @param array<string,mixed> $group @return array<string,mixed> */
private function finalizeGroup(array $group): array
{
$prices = array_keys((array) ($group['unitPrices'] ?? []));
if (count($prices) === 1) {
$group['unitPrice'] = (float) $prices[0];
} elseif ((float) $group['qty'] != 0.0) {
$group['unitPrice'] = round((float) $group['amount'] / (float) $group['qty'], 4);
}
return $group;
}
private function matchKey(string $purchaseNo, string $date, string $barcode): string
{
return $purchaseNo . "\0" . $date . "\0" . $barcode;
}
private function normalizePurchaseNo(mixed $value): string
{
$purchaseNo = ltrim(trim((string) $value), "'");
return preg_match('/^(\d+)\.0+$/', $purchaseNo, $matches) === 1 ? $matches[1] : $purchaseNo;
}
private function normalizeDate(mixed $value): string
{
$raw = trim((string) $value);
if (preg_match('/^(\d{4})[-\/.](\d{1,2})[-\/.](\d{1,2})/', $raw, $matches) === 1) {
$year = (int) $matches[1];
$month = (int) $matches[2];
$day = (int) $matches[3];
if (checkdate($month, $day, $year)) {
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
}
return $raw;
}
private function describeDifference(float $qtyDifference, float $amountDifference): string
{
$parts = [];
if (abs($qtyDifference) >= 0.005) {
$parts[] = '厂家数量' . ($qtyDifference > 0 ? '多 ' : '少 ') . $this->number(abs($qtyDifference));
}
if (abs($amountDifference) >= 0.005) {
$parts[] = '厂家金额' . ($amountDifference > 0 ? '多 ¥' : '少 ¥') . number_format(abs($amountDifference), 2);
}
return $parts === [] ? '数量、金额一致' : implode(',', $parts);
}
private function number(float $value): string
{
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
/** 去掉条码末尾的「厂家编号 + 版本」后缀,双方口径统一后再匹配。 */
private function normalizeBarcode(string $barcode): string
{
$normalized = preg_replace(self::SUPPLIER_SUFFIX, '', $barcode);
return $normalized === null || $normalized === '' ? $barcode : $normalized;
return strtoupper($normalized === null || $normalized === '' ? $barcode : $normalized);
}
}

View File

@ -12,7 +12,7 @@ use Modules\EcommerceFinance\Services\SupplierStatementStore;
/**
* 「供应商对账」页面数据:
* - 财务端数据:按供应商汇总 + 行级差异(条码 + 单价 匹配,四类结果带结算口径)
* - 财务端数据:按供应商汇总 + 行级差异(采购单号 + 日期 + 条码匹配,四类结果带结算口径)
* - 厂家端数据:厂家上传的对账单(实时)与只读的退货明细
*/
class SupplierReconciliationPageData

View File

@ -98,9 +98,10 @@
@endcanany
</form>
<p class="sr-hint">
比对口径:按「条码 + 单价」逐行核对(同一把钥匙的多行先合并数量与金额)。一致 → 正常结算;
厂家多(我方没有)→ 不结算、退回厂家修改账单;金额/数量不一致 → 取金额低的一方结算;
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家上传的对账单在「厂家端数据」实时可查。<br>
比对口径:按「采购单号 + 日期 + 条码」核对,同一采购单号、日期和条码的多行先合并数量与金额。
数量、金额一致 → 正常结算;厂家多(我方没有)→ 不结算、退回修改;
数量或金额不一致 → 明确列出双方数量、单价、金额及厂家多算部分,取金额低的一方结算;
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家未填采购单号时,仅在同日期+条码能唯一匹配我方一条数据时兜底。<br>
厂家登录:在「厂家登录账号管理」新增供应商并勾选可登录,供应商名称必须与对账数据中的厂家名称完全一致;
厂家登录后只能查看自己的对账单上传记录、全部退货明细及导出数据。
</p>
@ -242,9 +243,11 @@
<div class="sr-summary">
<div class="sr-summary-cell"><span>双方一致</span><b>{{ $detail['summary']['match'] }}</b></div>
<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']['mismatch'] }}</b></div>
<div class="sr-summary-cell"><span>厂家少(按厂家结算)</span><b>{{ $detail['summary']['supplierMissing'] }}</b></div>
<div class="sr-summary-cell"><span>我方金额合计</span><b>{{ number_format($detail['summary']['mineAmount'], 2) }}</b></div>
<div class="sr-summary-cell"><span>厂家金额合计</span><b>{{ number_format($detail['summary']['theirsAmount'], 2) }}</b></div>
<div class="sr-summary-cell"><span>厂家多算金额</span><b>{{ number_format($detail['summary']['supplierOverAmount'], 2) }}</b></div>
<div class="sr-summary-cell"><span>按口径结算合计</span><b>{{ number_format($detail['summary']['settleAmount'], 2) }}</b></div>
<div class="sr-summary-cell"><span>差异金额</span><b>{{ number_format($detail['summary']['differenceAmount'], 2) }}</b></div>
</div>
@ -256,12 +259,17 @@
<input id="select-all-differences" type="checkbox" aria-label="选择全部差异">
</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>
<th scope="col">数量差异(厂家-我方)</th>
<th scope="col">金额差异(厂家-我方)</th>
<th scope="col">结算口径</th>
<th scope="col">结算金额</th>
<th scope="col">推送</th>
@ -272,7 +280,7 @@
@php
$tagClass = match ($row['type']) {
'厂家多' => 'is-extra',
'金额不一致' => 'is-mismatch',
'数量/金额不一致' => 'is-mismatch',
'厂家少' => 'is-missing',
default => 'is-match',
};
@ -284,24 +292,41 @@
form="push-selected-form"
type="checkbox"
name="selected_rows[]"
value="{{ json_encode(['barcode' => $row['barcode'], 'type' => $row['type'], 'unitPrice' => $row['unitPrice']], JSON_UNESCAPED_UNICODE) }}"
value="{{ json_encode(['purchaseNo' => $row['purchaseNo'], 'date' => $row['date'], 'barcode' => $row['barcode'], 'type' => $row['type']], JSON_UNESCAPED_UNICODE) }}"
aria-label="选择差异 {{ $row['barcode'] }}">
@else
<span class="sr-muted">—</span>
@endif
</td>
<td><span class="sr-tag {{ $tagClass }}">{{ $row['type'] }}</span></td>
<td>{{ $row['purchaseNo'] !== '' ? $row['purchaseNo'] : '—' }}</td>
<td>{{ $row['date'] !== '' ? $row['date'] : '—' }}</td>
<td>
{{ $row['barcode'] }}
@if (! empty($row['supplierBarcode']))
<span class="sr-muted" style="font-size:.7rem">(厂家:{{ $row['supplierBarcode'] }})</span>
@endif
</td>
<td class="is-num">{{ number_format($row['unitPrice'], 2) }}</td>
<td class="is-num">{{ $row['mineQty'] !== 0.0 ? number_format($row['mineQty'], 2) : '—' }}</td>
<td class="is-num">{{ $row['theirsQty'] !== 0.0 ? number_format($row['theirsQty'], 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">{{ $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>
<td class="is-num">
@if (abs($row['qtyDifference']) < 0.005)
一致
@else
厂家{{ $row['qtyDifference'] > 0 ? '多' : '少' }} {{ number_format(abs($row['qtyDifference']), 2) }}
@endif
</td>
<td class="is-num">
@if (abs($row['amountDifference']) < 0.005)
一致
@else
厂家{{ $row['amountDifference'] > 0 ? '多' : '少' }} ¥{{ number_format(abs($row['amountDifference']), 2) }}
@endif
</td>
<td class="sr-muted">{{ $row['note'] }}</td>
<td class="is-num">{{ number_format($row['settleAmount'], 2) }}</td>
<td>
@ -314,9 +339,10 @@
<input type="hidden" name="month" value="{{ $month }}">
<input type="hidden" name="reconciler" value="{{ $reconciler }}">
<input type="hidden" name="scope" value="row">
<input type="hidden" name="purchase_no" value="{{ $row['purchaseNo'] }}">
<input type="hidden" name="date" value="{{ $row['date'] }}">
<input type="hidden" name="barcode" value="{{ $row['barcode'] }}">
<input type="hidden" name="difference_type" value="{{ $row['type'] }}">
<input type="hidden" name="unit_price" value="{{ number_format($row['unitPrice'], 4, '.', '') }}">
<button type="submit"
class="sr-button is-small {{ $robot['configured'] ? 'is-primary' : 'is-disabled' }}"
@disabled(! $robot['configured'])
@ -330,7 +356,7 @@
</td>
</tr>
@empty
<tr><td colspan="11"><div class="sr-empty">该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。</div></td></tr>
<tr><td colspan="16"><div class="sr-empty">该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。</div></td></tr>
@endforelse
</tbody>
</table>

View File

@ -107,7 +107,7 @@
</div>
@endif
<p class="sr-hint">
厂家只能按模板上传(表头列名与顺序必须与模板完全一致):快递名称、快递单号、单据编号(采购单号)可以留空;
厂家只能按模板上传(表头列名与顺序必须与模板完全一致):快递名称、快递单号可以留空;采购单号建议填写(用于按采购单号+日期+条码核对,未填时仅在日期+条码唯一匹配时自动匹配);
单价或金额留空时按数量自动补齐(都留空按 0,未定价行可直接导出上传);其余列必填;
不符合模板或数值格式的整份失败并提示具体行号。厂家上传成功后,下面的数据与「财务端数据」的比对结果立即更新。
</p>

View File

@ -89,7 +89,8 @@
</form>
</div>
<p class="sp-hint">
请在每月 5 号前上传上月对账单。表头名称、顺序和列数必须与模板完全一致;快递名称、快递单号、采购单号可留空,
请在每月 5 号前上传上月对账单。表头名称、顺序和列数必须与模板完全一致;快递名称、快递单号可留空;
采购单号建议填写,系统会按采购单号+日期+条码与我方数据核对;
单价或金额留空时会按数量自动补齐。
</p>
<div class="sp-summary">

View File

@ -105,6 +105,9 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
&& str_contains($text, 'B-1')
&& str_contains($text, 'C-1')
&& str_contains($text, 'D-1')
&& str_contains($text, '采购单号:编号-B-1')
&& str_contains($text, '日期:2026-09-30')
&& str_contains($text, '厂家数量少 1')
&& ! str_contains($text, '`A-1`')
&& ! str_contains($text, '鸣飞家具');
});
@ -126,9 +129,10 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
'month' => '2026-09',
'reconciler' => '张三',
'scope' => 'row',
'purchase_no' => '编号-B-1',
'date' => '2026-09-30',
'barcode' => 'B-1',
'difference_type' => '金额不一致',
'unit_price' => '50',
'difference_type' => '数量/金额不一致',
])
->assertStatus(302)
->assertSessionHas('success', '已推送 1 条差异到 晨丰 钉钉群(1 条消息)。');
@ -155,8 +159,8 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
'reconciler' => '张三',
'scope' => 'selected',
'selected_rows' => [
json_encode(['barcode' => 'B-1', 'type' => '金额不一致', 'unitPrice' => 50]),
json_encode(['barcode' => 'D-1', 'type' => '厂家多', 'unitPrice' => 80]),
json_encode(['purchaseNo' => '编号-B-1', 'date' => '2026-09-30', 'barcode' => 'B-1', 'type' => '数量/金额不一致']),
json_encode(['purchaseNo' => '编号-D-1', 'date' => '2026-09-30', 'barcode' => 'D-1', 'type' => '厂家多']),
],
])
->assertStatus(302)
@ -198,14 +202,20 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
$rows[] = [
'barcode' => sprintf('BULK-%02d', $index),
'supplierBarcode' => '',
'purchaseNo' => sprintf('PO-BULK-%02d', $index),
'date' => '2026-09-30',
'unitPrice' => 10,
'mineUnitPrice' => 10,
'theirsUnitPrice' => 10,
'mineQty' => 2,
'theirsQty' => 1,
'mineAmount' => 20,
'theirsAmount' => 10,
'type' => '金额不一致',
'qtyDifference' => -1,
'amountDifference' => -10,
'type' => '数量/金额不一致',
'settleAmount' => 10,
'note' => '金额/数量不一致:按金额低的一方结算',
'note' => '厂家数量少 1;按金额低的一方结算',
];
}
@ -230,6 +240,8 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
] as $key => $row) {
$store->save('final:supplier:payment-reconciliation', $key, $row + [
'summary_month' => '2026-09',
'purchaseNo' => '编号-' . $row['skuCode'],
'date' => '2026-09-30',
'supplier' => '晨丰',
'reconciler' => '张三',
], $companyId, null);

View File

@ -46,6 +46,7 @@ class SupplierReconciliationTest extends FeatureTestCase
->assertSeeText('厂家端数据')
->assertSeeText('厂家多')
->assertSeeText('取金额低的一方结算')
->assertSeeText('采购单号 + 日期 + 条码')
->assertSeeText('对账人')
->assertSeeText('下载对账单模板');
}
@ -55,11 +56,13 @@ class SupplierReconciliationTest extends FeatureTestCase
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
$store->save('final:supplier:payment-reconciliation', 'line:000001', [
'summary_month' => '2026-09', 'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-1', 'date' => '2026-09-30',
'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
'qty' => '2', 'unitPrice' => '128', 'amount' => '256', 'reconciler' => '张三',
], $companyId, null);
$store->save('final:supplier:payment-reconciliation', 'line:000002', [
'summary_month' => '2026-09', 'supplier' => '鸣飞家具', 'skuCode' => 'SJ-1',
'summary_month' => '2026-09', 'purchaseNo' => '编号-SJ-1', 'date' => '2026-09-30',
'supplier' => '鸣飞家具', 'skuCode' => 'SJ-1',
'qty' => '1', 'unitPrice' => '96', 'amount' => '96', 'reconciler' => '李四',
], $companyId, null);
@ -81,15 +84,17 @@ class SupplierReconciliationTest extends FeatureTestCase
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
$store->save('final:supplier:payment-reconciliation', 'line:000001', [
'summary_month' => '2026-09', 'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-1N1097V01', 'date' => '2026-09-30',
'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
'qty' => '2', 'unitPrice' => '128', 'amount' => '256', 'reconciler' => '张三',
], $companyId, null);
$store->save('final:supplier:payment-reconciliation', 'line:000002', [
'summary_month' => '2026-09', 'supplier' => '晨丰', 'skuCode' => 'ZSJ-2',
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-2N1097V01', 'date' => '2026-09-30',
'supplier' => '晨丰', 'skuCode' => 'ZSJ-2',
'qty' => '1', 'unitPrice' => '50', 'amount' => '50', 'reconciler' => '张三',
], $companyId, null);
$this->seedStatement('晨丰', [
$this->statementRow('ZSJ-1N1097V01', '128.00', '2', '256.00'),
$this->statementRow('ZSJ-1N1097V01', '128.00', '3', '384.00'),
$this->statementRow('ZSJ-3N1097V01', '80.00', '1', '80.00'),
]);
@ -103,6 +108,12 @@ class SupplierReconciliationTest extends FeatureTestCase
->assertSeeText('比对明细')
->assertSeeText('返回供应商列表')
->assertSeeText('结算金额')
->assertSeeText('采购单号')
->assertSeeText('日期')
->assertSeeText('数量差异(厂家-我方)')
->assertSeeText('厂家多 1.00')
->assertSeeText('厂家多 ¥128.00')
->assertSeeText('编号-ZSJ-1N1097V01')
->assertSeeText('ZSJ-3')
->assertSeeText('ZSJ-2')
->assertDontSeeText('各厂家上传情况');
@ -365,6 +376,8 @@ class SupplierReconciliationTest extends FeatureTestCase
'mine:logistics:1',
[
'summary_month' => '2026-09',
'purchaseNo' => 'PO-LOGISTICS-1',
'date' => '2026-09-27',
'supplier' => '曹县宇诺',
'reconciler' => '陈亲园',
'skuCode' => 'CZ008407',
@ -835,14 +848,14 @@ class SupplierReconciliationTest extends FeatureTestCase
public function test_matcher_applies_the_agreed_settlement_rules(): void
{
$mine = [
['skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'], // 一致
['skuCode' => 'B-1', 'unitPrice' => '50', 'qty' => '2', 'amount' => '100'], // 金额不一致(厂家低)
['skuCode' => 'C-1', 'unitPrice' => '30', 'qty' => '1', 'amount' => '30'], // 厂家少
['purchaseNo' => 'PO-A-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
['purchaseNo' => 'PO-B-1', 'date' => '2026-09-30', 'skuCode' => 'B-1', 'unitPrice' => '50', 'qty' => '2', 'amount' => '100'],
['purchaseNo' => 'PO-C-1', 'date' => '2026-09-30', 'skuCode' => 'C-1', 'unitPrice' => '30', 'qty' => '1', 'amount' => '30'],
];
$theirs = [
['barcode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
['barcode' => 'B-1', 'unitPrice' => '50', 'qty' => '1', 'amount' => '50'],
['barcode' => 'D-1', 'unitPrice' => '80', 'qty' => '1', 'amount' => '80'], // 厂家多
['purchaseNo' => 'PO-A-1', 'createdAt' => '2026-09-30', 'barcode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
['purchaseNo' => 'PO-B-1', 'createdAt' => '2026-09-30', 'barcode' => 'B-1', 'unitPrice' => '50', 'qty' => '1', 'amount' => '50'],
['purchaseNo' => 'PO-D-1', 'createdAt' => '2026-09-30', 'barcode' => 'D-1', 'unitPrice' => '80', 'qty' => '1', 'amount' => '80'],
];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
@ -852,11 +865,17 @@ class SupplierReconciliationTest extends FeatureTestCase
$this->assertSame(1, $result['summary']['supplierMissing']);
$this->assertSame(1, $result['summary']['supplierExtra']);
$this->assertSame(330.0, $result['summary']['mineAmount']);
$this->assertSame(330.0, $result['summary']['theirsAmount']);
$this->assertSame(80.0, $result['summary']['supplierOverAmount']);
$this->assertSame(250.0, $result['summary']['settleAmount']);
$this->assertSame(80.0, $result['summary']['differenceAmount']);
$byBarcode = collect($result['rows'])->keyBy('barcode');
$this->assertSame(50.0, $byBarcode['B-1']['settleAmount']);
$this->assertSame(-1.0, $byBarcode['B-1']['qtyDifference']);
$this->assertSame(-50.0, $byBarcode['B-1']['amountDifference']);
$this->assertSame('PO-B-1', $byBarcode['B-1']['purchaseNo']);
$this->assertSame('2026-09-30', $byBarcode['B-1']['date']);
$this->assertSame(0.0, $byBarcode['C-1']['settleAmount']);
$this->assertSame(0.0, $byBarcode['D-1']['settleAmount']);
}
@ -865,12 +884,12 @@ class SupplierReconciliationTest extends FeatureTestCase
{
// 真实场景:我方货号不带厂家后缀,厂家条码带「厂家编号 + 版本」(CJ019312 对 CJ019312N1097V01)
$mine = [
['skuCode' => 'CJ019312', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
['skuCode' => 'DNZ037602', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
['purchaseNo' => 'PO-CJ-1', 'date' => '2026-09-30', 'skuCode' => 'CJ019312', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
['purchaseNo' => 'PO-DNZ-1', 'date' => '2026-09-30', 'skuCode' => 'DNZ037602', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
];
$theirs = [
['barcode' => 'CJ019312N1097V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
['barcode' => 'DNZ037602N1097V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
['purchaseNo' => 'PO-CJ-1', 'createdAt' => '2026-09-30', 'barcode' => 'CJ019312N1097V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
['purchaseNo' => 'PO-DNZ-1', 'createdAt' => '2026-09-30', 'barcode' => 'DNZ037602N1097V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
@ -883,6 +902,65 @@ class SupplierReconciliationTest extends FeatureTestCase
$this->assertSame('CJ019312N1097V01', $result['rows'][0]['supplierBarcode'] ?? null);
}
public function test_matcher_uses_purchase_number_date_and_barcode_as_the_unique_key(): void
{
$mine = [
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
['purchaseNo' => 'PO-2', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '1', 'amount' => '100'],
];
$theirs = [
['purchaseNo' => 'PO-1', 'createdAt' => '2026/9/30 09:30:00', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '3', 'amount' => '300'],
['purchaseNo' => 'PO-3', 'createdAt' => '2026-10-01', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
$rows = collect($result['rows'])->keyBy('purchaseNo');
$this->assertSame(SupplierStatementMatcher::TYPE_MISMATCH, $rows['PO-1']['type']);
$this->assertSame('2026-09-30', $rows['PO-1']['date']);
$this->assertSame(1.0, $rows['PO-1']['qtyDifference']);
$this->assertSame(100.0, $rows['PO-1']['amountDifference']);
$this->assertStringContainsString('厂家数量多 1', $rows['PO-1']['note']);
$this->assertSame(SupplierStatementMatcher::TYPE_SUPPLIER_MISSING, $rows['PO-2']['type']);
$this->assertSame(SupplierStatementMatcher::TYPE_SUPPLIER_EXTRA, $rows['PO-3']['type']);
$this->assertSame(3.0, $result['summary']['supplierOverQty']);
$this->assertSame(300.0, $result['summary']['supplierOverAmount']);
}
public function test_matcher_can_fallback_only_when_missing_purchase_number_matches_one_row(): void
{
$mine = [
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
];
$theirs = [
['purchaseNo' => '', 'createdAt' => '2026-09-30', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
$this->assertSame(1, $result['summary']['match']);
$this->assertSame('PO-1', $result['rows'][0]['purchaseNo']);
$this->assertStringContainsString('按日期+条码唯一匹配', $result['rows'][0]['note']);
}
public function test_matcher_does_not_guess_when_missing_purchase_number_is_ambiguous(): void
{
$mine = [
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '1', 'amount' => '100'],
['purchaseNo' => 'PO-2', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
];
$theirs = [
['purchaseNo' => '', 'createdAt' => '2026-09-30', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '3', 'amount' => '300'],
];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
$this->assertSame(0, $result['summary']['match']);
$this->assertSame(1, $result['summary']['supplierExtra']);
$this->assertSame(2, $result['summary']['supplierMissing']);
$this->assertStringContainsString('无法唯一匹配', $result['rows'][0]['note']);
}
public function test_header_mismatch_message_lists_the_differences(): void
{
$file = UploadedFile::fake()->createWithContent(