diff --git a/docs/厂家记账金额规则.md b/docs/厂家记账金额规则.md new file mode 100644 index 00000000..e641b42a --- /dev/null +++ b/docs/厂家记账金额规则.md @@ -0,0 +1,33 @@ +# 厂家记账金额规则 + +本轮为只读计算,保留工单原金额及原备注,不回写上游工单。页面、扣款筛选、合计、CSV 导出共用同一计算结果。 + +## 判定顺序 + +1. 对“补偿款项”“记账全款”(兼容“记账全额”),原金额大于0且小于50元,先判断原金额÷商品买家实付合计是否小于40%。命中后金额为0,不扣款,追加备注 `小于50且小于0.4,记账改0`。 +2. 未命中的,再用工单 `actual_paid_amount`(页面“实付金额”)作分母,独立判断同样的严格边界。任一轮实付缺失、无效或为0时,只跳过该轮;另一轮仍可正常判断。多商品买家实付缺一项时,不把部分金额当作完整合计。 +3. 保留厂家记账配置、代发仓发错货限制和多供应商待确认规则。只有已判定可扣款的剩余工单进入核价,不用核价推翻“不扣款”或“待确认”。 +4. “补偿款项”“记账全款”按原单商品的实际发货数量乘普通商品资料当前成本单价,再加实际包裹运费,与原金额取较小值。成本为当前 `cost_price`,不代表历史月份成本。 +5. “运费损耗”按实际运费记账,不受原记账金额限制;原记账金额为0也需要匹配。匹配结果确为0时不扣款。 + +金额等于50元或比例等于40%均不满足小额免记账。金额计算使用十进制运算,在最终金额处四舍五入到分。 + +## 发货匹配 + +- 查找时间为工单发起月份及前五个月,右端为下月1日,不含该日。例如10月工单读取5月1日至11月1日前的发货记录。 +- 有物流单号时精确匹配该单号,同时核对工单提供的线上订单号;无物流单号时按线上订单号精确匹配全部相关包裹。 +- 不以同商品平均运费替代原单运费,不借同订单其他快递单的运费;多个工单单号只匹配一部分时标为待确认。 +- 工单列出的商品编码须全部在原单发货明细中匹配。仅累计对应商品数量和成本,包裹运费按实际明细累计一次。 +- 缺发货、有效数量、成本价或运费时标为待确认,保留原金额,不计入扣款合计。明确的0运费有效,空运费不能当0。 +- 新增“原记账金额、匹配商品成本、匹配实际运费、匹配发货数量、匹配快递单号、发货匹配期间”供核对与导出。 + +## 当前接入状态与发布前条件 + +当前核价仓库复用财务系统现有发货数据表 `jst_oms_shipping`,定点批量查询物流单号/线上订单号,商品成本复用普通商品资料镜像。每批单号最多150个,发货按主键每500行读取,不扫描六个月全表。 + +**此版本仍为草稿,尚不能作为工作平台历史数据接入完成的交付。** 用户要求工作平台原始发货数据;现有财务系统发货记录的历史覆盖不足,不能把它当作完整六个月数据。发布前必须核实工作平台 `fys_omsDataDayDetail` 等正式来源、运费字段口径、5/6月历史覆盖及代表性原单结果,再完成对应镜像接入和验收;不得以部分现有数据试算代替该条件。 + +## 验证范围 + +- 相关单元和页面测试覆盖小额两轮严格边界、多商品缺值、成本封顶、实际运费、原金额为0的运费损耗、原单/多包裹/跨年窗口、缺项待确认、既有仓库与厂家配置、页面/筛选/CSV 一致性。 +- 生产只读试算、耗时和视觉证据保存于忽略的私有目录,不在仓库保存真实财务明细、日志或截图。试算不等于上线,也不等于历史来源已验证。 diff --git a/modules/EcommerceFinance/Repositories/SupplierBookkeepingValuationRepository.php b/modules/EcommerceFinance/Repositories/SupplierBookkeepingValuationRepository.php new file mode 100644 index 00000000..9c8f3706 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/SupplierBookkeepingValuationRepository.php @@ -0,0 +1,114 @@ +> */ + public function shipmentsFor(array $rows): array + { + if ($rows === []) return []; + $db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local')); + if (!Schema::connection($db->getName())->hasTable('jst_oms_shipping')) { + throw new RuntimeException('发货数据尚未就绪'); + } + $from = null; + $to = null; + $tracking = []; + $orders = []; + foreach ($rows as $row) { + [$start, $end] = self::window($row); + $from = $from === null || $start < $from ? $start : $from; + $to = $to === null || $end > $to ? $end : $to; + $numbers = self::numbers($row['tracking_no'] ?? ''); + if ($numbers !== []) { + array_push($tracking, ...$numbers); + } else { + array_push($orders, ...self::numbers($row['online_order_no'] ?? '')); + } + } + $found = []; + // 使用现有快递单号/交易单号索引分批定点读取,不扫描六个月全量发货数据。 + foreach (['express_no' => $tracking, 'trade_no' => $orders] as $column => $values) { + foreach (array_chunk(array_values(array_unique($values)), 150) as $chunk) { + $query = $db->table('jst_oms_shipping')->whereIn($column, $chunk) + ->where('ship_time', '>=', $from)->where('ship_time', '<', $to) + ->select(['id', 'source_id', 'express_no', 'trade_no', 'barcode', 'qty', 'freight', 'ship_time']) + ->orderBy('id'); + foreach ($query->lazyById(500) as $record) { + $found[$record->id] = (array) $record; + } + } + } + return array_values($found); + } + + /** @return array 规范化 SKU => 当前商品资料成本单价 */ + public function costsFor(array $skus): array + { + if ($skus === []) return []; + $dataset = $this->datasets->get(ProductCatalogMirrorRepository::DATASET_KEY); + if (!$this->mirror->ready($dataset)) { + throw new RuntimeException('商品资料尚未完成首次同步'); + } + $db = $this->mirror->connection(); + $table = $dataset->targetTable(); + $values = []; + foreach ($skus as $sku) { + $normalized = self::sku($sku); + foreach ([$normalized, mb_strtolower($normalized), str_replace('+', '_X002B_', $normalized)] as $value) { + if ($value !== '') $values[$value] = true; + } + } + $result = []; + $latest = []; + $exclusions = Schema::connection($db->getName())->hasTable('product_catalog_exclusions'); + foreach (array_chunk(array_keys($values), 150) as $chunk) { + $query = $db->table($table)->where('sku_type', 'normal')->whereIn('sku_id', $chunk); + if ($exclusions) { + $query->whereNotExists(static function ($q) use ($table): void { + $q->selectRaw('1')->from('product_catalog_exclusions AS e') + ->whereColumn('e.mirror_key', $table . '._mirror_key') + ->whereRaw("e.sku_id_normalized = REPLACE(UPPER(TRIM({$table}.sku_id)), '_X002B_', '+')"); + }); + } + foreach ($query->orderByDesc('autoid')->get(['autoid', 'sku_id', 'cost_price']) as $record) { + $key = self::sku($record->sku_id); + if (!isset($latest[$key]) || (int) $record->autoid > $latest[$key]) { + $latest[$key] = (int) $record->autoid; + $result[$key] = $record->cost_price === null ? null : (string) $record->cost_price; + } + } + } + return $result; + } + + /** 记账所在月份及前五个月,右端为下月1日(不含)。 */ + public static function window(array $row): array + { + $month = Carbon::parse((string) $row['started_at'])->startOfMonth(); + return [$month->copy()->subMonthsNoOverflow(5)->format('Y-m-d H:i:s'), $month->addMonth()->format('Y-m-d H:i:s')]; + } + + public static function numbers(mixed $value): array + { + return array_values(array_unique(array_filter(array_map('trim', preg_split('/[\r\n,,、;;]+/u', (string) $value) ?: []), static fn ($v) => $v !== ''))); + } + + public static function sku(mixed $value): string + { + return str_replace('_X002B_', '+', mb_strtoupper(trim((string) $value))); + } +} diff --git a/modules/EcommerceFinance/Repositories/SupplierBookkeepingWorkOrderRepository.php b/modules/EcommerceFinance/Repositories/SupplierBookkeepingWorkOrderRepository.php index c9192168..7225a276 100644 --- a/modules/EcommerceFinance/Repositories/SupplierBookkeepingWorkOrderRepository.php +++ b/modules/EcommerceFinance/Repositories/SupplierBookkeepingWorkOrderRepository.php @@ -167,7 +167,7 @@ class SupplierBookkeepingWorkOrderRepository throw new RuntimeException('business_mirror_dataset_not_ready'); } - $key = 'supplier-bookkeeping:rows:v1:' . sha1((string) $status['last_succeeded_at']); + $key = 'supplier-bookkeeping:rows:v2:' . sha1((string) $status['last_succeeded_at']); $cached = Cache::get($key); if (is_array($cached)) { return $cached; @@ -256,7 +256,8 @@ class SupplierBookkeepingWorkOrderRepository $items = $this->items($fields['itemInfo']['value'] ?? null, $fields['itemInfo']['raw'] ?? null); foreach (self::ITEM_FIELDS as $key => $candidates) { $values = array_map(fn (array $item): string => $this->firstOf($item, $candidates), $items); - $row[$key] = implode("\n", array_filter($values, static fn (string $v): bool => $v !== '')); + // 保留买家实付中的空商品行,防止不完整金额被误当作整单实付。 + $row[$key] = implode("\n", $key === 'buyer_paid_amount' ? $values : array_filter($values, static fn (string $v): bool => $v !== '')); } if ($row['supplier'] === '') { $row['supplier'] = $row['header_supplier']; diff --git a/modules/EcommerceFinance/Services/SupplierBookkeepingDeductionRule.php b/modules/EcommerceFinance/Services/SupplierBookkeepingDeductionRule.php index ea0514ff..acd94765 100644 --- a/modules/EcommerceFinance/Services/SupplierBookkeepingDeductionRule.php +++ b/modules/EcommerceFinance/Services/SupplierBookkeepingDeductionRule.php @@ -34,6 +34,8 @@ class SupplierBookkeepingDeductionRule public const DROPSHIP_WAREHOUSE_KEYWORD = '代发仓'; public const WAREHOUSE_WRONG_KEYWORD = '仓库发错货'; + public const SMALL_AMOUNT_NOTE = '小于50且小于0.4,记账改0'; + /** * 解析配置表中“厂家是否记账”原文,例如“代发仓发货错需记账,其他原因均不记账”“不记账(2025.3月-2027.12.30)”。 * @@ -91,7 +93,7 @@ class SupplierBookkeepingDeductionRule /** * @param array $row 厂家记账明细行 * @param array|object> $configs 供应商名称 => 生效中的配置 - * @return array{supplier_config:string,deduction_status:string,deduction_basis:string} + * @return array{supplier_config:string,deduction_status:string,deduction_basis:string,amount?:string,remark?:string} */ public static function decide(array $row, array $configs): array { @@ -106,6 +108,15 @@ class SupplierBookkeepingDeductionRule } $configText = implode("\n", array_map(static fn (?array $config): string => $config === null ? '未在配置表(按正常记账)' : self::describe($config), $found)); + if (self::isSmallCompensation($row)) { + $remark = (string) ($row['remark'] ?? ''); + + return self::result($configText, self::STATUS_SKIP, self::SMALL_AMOUNT_NOTE) + [ + 'amount' => '0.00', + 'remark' => $remark === '' ? self::SMALL_AMOUNT_NOTE : $remark . "\n" . self::SMALL_AMOUNT_NOTE, + ]; + } + if ($level1 === '仓库记账') { $warehouse = (string) ($row['shipping_warehouse'] ?? ''); if (! str_contains($warehouse, self::DROPSHIP_WAREHOUSE_KEYWORD) || ! str_contains($level2, self::WAREHOUSE_WRONG_KEYWORD)) { @@ -133,6 +144,38 @@ class SupplierBookkeepingDeductionRule return self::result($configText, $statuses[0], reset($outcomes)[1]); } + private static function isSmallCompensation(array $row): bool + { + if (! in_array(trim((string) ($row['bookkeeping_type'] ?? '')), ['补偿款项', '记账全款', '记账全额'], true)) { + return false; + } + + $amount = trim((string) ($row['amount'] ?? '')); + if (! preg_match('/^\d+(?:\.\d{1,6})?$/D', $amount) + || bccomp($amount, '0', 6) <= 0 || bccomp($amount, '50', 6) >= 0) { + return false; + } + + // 两轮依次使用商品买家实付合计、工单订单实付;一轮缺失不影响另一轮独立判断。 + foreach (['buyer_paid_amount', 'actual_paid_amount'] as $field) { + $paid = '0'; + foreach (explode("\n", (string) ($row[$field] ?? '')) as $line) { + $line = trim($line); + if (! preg_match('/^\d+(?:\.\d{1,6})?$/D', $line)) { + continue 2; + } + $paid = bcadd($paid, $line, 6); + } + // 交叉相乘避免除零、比例取整和浮点边界误差。 + if (bccomp($paid, '0', 6) > 0 + && bccomp(bcmul($amount, '5', 6), bcmul($paid, '2', 6), 6) < 0) { + return true; + } + } + + return false; + } + /** @return array{0:string,1:string} [判定, 说明] */ private static function outcome(string $level1, ?array $config, string $date): array { diff --git a/modules/EcommerceFinance/Services/SupplierBookkeepingValuation.php b/modules/EcommerceFinance/Services/SupplierBookkeepingValuation.php new file mode 100644 index 00000000..fc51fb54 --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierBookkeepingValuation.php @@ -0,0 +1,147 @@ +> $rows @return list> */ + public function apply(array $rows): array + { + $candidates = array_filter($rows, fn ($row) => $this->needsValuation($row)); + if ($candidates === []) return $rows; + try { + $shipments = $this->repository->shipmentsFor(array_values($candidates)); + } catch (Throwable $exception) { + report($exception); + foreach (array_keys($candidates) as $key) $rows[$key] = $this->review($rows[$key], '发货数据暂不可读取,请核对后记账'); + return $rows; + } + + $byTracking = []; + $byOrder = []; + foreach ($shipments as $shipment) { + $byTracking[(string) $shipment['express_no']][] = $shipment; + $byOrder[(string) $shipment['trade_no']][] = $shipment; + } + $skus = []; + foreach ($candidates as $row) { + if ($row['bookkeeping_type'] !== '运费损耗') array_push($skus, ...Repository::numbers($row['product_code'] ?? '')); + } + try { + $costs = $this->repository->costsFor(array_values(array_unique($skus))); + } catch (Throwable $exception) { + report($exception); + $costs = []; + } + foreach (array_keys($candidates) as $key) { + $rows[$key] = $this->value($rows[$key], $byTracking, $byOrder, $costs); + } + return $rows; + } + + private function needsValuation(array $row): bool + { + return ($row['deduction_status'] ?? '') === SupplierBookkeepingDeductionRule::STATUS_DEDUCT + && in_array($row['bookkeeping_type'] ?? '', ['补偿款项', '记账全款', '记账全额', '运费损耗'], true) + && self::decimal($row['amount'] ?? null) + && (bccomp((string) $row['amount'], '0', 6) > 0 || $row['bookkeeping_type'] === '运费损耗'); + } + + private function value(array $row, array $byTracking, array $byOrder, array $costs): array + { + [$from, $to] = Repository::window($row); + $row['valuation_window'] = substr($from, 0, 7) . ' 至 ' . substr((string) $row['started_at'], 0, 7); + $tracking = Repository::numbers($row['tracking_no'] ?? ''); + $orders = Repository::numbers($row['online_order_no'] ?? ''); + $pool = []; + foreach ($tracking !== [] ? $tracking : $orders as $number) { + foreach (($tracking !== [] ? $byTracking : $byOrder)[$number] ?? [] as $shipment) { + if ($shipment['ship_time'] < $from || $shipment['ship_time'] >= $to) continue; + if ($tracking !== [] && $orders !== [] && !in_array((string) $shipment['trade_no'], $orders, true)) continue; + $pool[$shipment['id']] = $shipment; + } + } + if ($pool === []) return $this->review($row, '六个月内未匹配到原订单/快递单号的发货记录'); + $matchedReferences = array_column($pool, $tracking !== [] ? 'express_no' : 'trade_no'); + if (array_diff($tracking !== [] ? $tracking : $orders, $matchedReferences) !== []) { + return $this->review($row, '原单号包含未匹配发货记录,暂不能按部分金额记账'); + } + + $packages = []; + $quantity = '0'; + $cost = '0'; + $wanted = array_fill_keys(array_map([Repository::class, 'sku'], Repository::numbers($row['product_code'] ?? '')), false); + $freightOnly = $row['bookkeeping_type'] === '运费损耗'; + if (!$freightOnly && $wanted === []) return $this->review($row, '工单没有商品编码,无法匹配成本'); + foreach ($pool as $shipment) { + $package = (string) $shipment['source_id']; + $packages[$package] ??= ['amount' => '0', 'has_freight' => false]; + if ($shipment['freight'] !== null && $shipment['freight'] !== '') { + if (!self::decimal($shipment['freight'])) return $this->review($row, '原单运费无效,请核对'); + $packages[$package]['amount'] = bcadd($packages[$package]['amount'], (string) $shipment['freight'], 6); + $packages[$package]['has_freight'] = true; + } + $sku = Repository::sku($shipment['barcode']); + if (!$freightOnly && !array_key_exists($sku, $wanted)) continue; + if (!self::decimal($shipment['qty']) || bccomp((string) $shipment['qty'], '0', 6) <= 0) { + return $this->review($row, '匹配发货数量缺失或无效'); + } + $quantity = bcadd($quantity, (string) $shipment['qty'], 6); + if (!$freightOnly) { + $price = $costs[$sku] ?? null; + if (!self::decimal($price) || bccomp((string) $price, '0', 6) <= 0) return $this->review($row, '商品成本价缺失或为0:' . $sku); + $cost = bcadd($cost, bcmul((string) $price, (string) $shipment['qty'], 6), 6); + $wanted[$sku] = true; + } + } + if (!$freightOnly && in_array(false, $wanted, true)) return $this->review($row, '原单发货明细未匹配全部工单商品'); + $freight = '0'; + foreach ($packages as $package) { + if (!$package['has_freight']) return $this->review($row, '原单运费尚未补齐,不能按0记账'); + $freight = bcadd($freight, $package['amount'], 6); + } + $row['matched_freight'] = self::money($freight); + $row['matched_quantity'] = rtrim(rtrim($quantity, '0'), '.'); + $row['matched_tracking_no'] = implode("\n", array_values(array_unique(array_column($pool, 'express_no')))); + if ($freightOnly) { + $row['amount'] = self::money($freight); + $basis = '运费损耗按原单实际运费记账'; + } else { + $row['matched_cost'] = self::money($cost); + $ceiling = self::money(bcadd($cost, $freight, 6)); + if (bccomp((string) $row['amount'], $ceiling, 2) > 0) { + $row['amount'] = $ceiling; + $basis = '原记账金额大于商品成本+实际运费,按成本+运费记账'; + } else { + $basis = '原记账金额不大于商品成本+实际运费,保留记账金额'; + } + } + $row['deduction_basis'] .= ';' . $basis; + $row['remark'] = trim((string) ($row['remark'] ?? '')) === '' ? $basis : $row['remark'] . "\n" . $basis; + if (bccomp((string) $row['amount'], '0', 2) === 0) $row['deduction_status'] = SupplierBookkeepingDeductionRule::STATUS_SKIP; + return $row; + } + + private function review(array $row, string $reason): array + { + $row['deduction_status'] = SupplierBookkeepingDeductionRule::STATUS_REVIEW; + $row['deduction_basis'] .= ';' . $reason; + return $row; + } + + private static function decimal(mixed $value): bool + { + return $value !== null && preg_match('/^\d+(?:\.\d{1,6})?$/D', (string) $value) === 1; + } + + private static function money(string $value): string + { + return bcadd($value, '0.005', 2); + } +} diff --git a/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php b/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php index 008ebd33..5f059f16 100644 --- a/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php +++ b/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php @@ -6,6 +6,7 @@ use Illuminate\Pagination\LengthAwarePaginator; use Modules\EcommerceFinance\Repositories\SupplierBookkeepingWorkOrderRepository as Repository; use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore; use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule; +use Modules\EcommerceFinance\Services\SupplierBookkeepingValuation; /** 供应商应付 → 厂家记账(聚工单“记账”工单镜像)。 */ class SupplierBookkeepingPageData @@ -27,6 +28,9 @@ class SupplierBookkeepingPageData 'started_at' => '发起时间', 'work_order_no' => '工单编号', 'initiator' => '发起人', 'bookkeeping_reason' => '记账原因', 'reason_level_1' => '记账原因-一级选项', 'reason_level_2' => '记账原因-二级选项', 'bookkeeping_type' => '记账类型', 'executor' => '执行人', 'source' => '记账来源', 'amount' => '记账金额', + 'original_amount' => '原记账金额', + 'matched_cost' => '匹配商品成本', 'matched_freight' => '匹配实际运费', 'matched_quantity' => '匹配发货数量', + 'matched_tracking_no' => '匹配快递单号', 'valuation_window' => '发货匹配期间', 'deduction_status' => '厂家扣款判定', 'supplier_config' => '厂家记账配置', 'deduction_basis' => '判定说明', 'remark' => '记账备注说明', 'attachment' => '上传附件', 'online_order_no' => '线上订单号', 'internal_order_no' => '内部单号', 'actual_paid_amount' => '实付金额', 'carrier' => '物流公司', @@ -40,11 +44,12 @@ class SupplierBookkeepingPageData 'accepting_department' => '受理部门', 'order_shipped_at' => '订单发货时间', ]; - public const MONEY_COLUMNS = ['amount', 'actual_paid_amount']; + public const MONEY_COLUMNS = ['amount', 'original_amount', 'matched_cost', 'matched_freight', 'actual_paid_amount']; public function __construct( private readonly Repository $repository, private readonly SupplierBookkeepingConfigStore $configs, + private readonly SupplierBookkeepingValuation $valuation, ) {} /** @return array */ @@ -99,7 +104,10 @@ class SupplierBookkeepingPageData $to = $filters['started_to'] !== '' ? $filters['started_to'] . ' 23:59:59' : ''; $deduction = $filters['deduction'] ?? ''; $configs = $this->configs->activeConfigs(); + $matched = []; foreach ($rows ?? $this->repository->rows() as $row) { + $row['original_amount'] = $row['amount']; + $row += ['matched_cost' => null, 'matched_freight' => null, 'matched_quantity' => '', 'matched_tracking_no' => '', 'valuation_window' => '']; // 配置随时可改,判定不进镜像结果缓存,每次按当前配置计算。 $row = Rule::decide($row, $configs) + $row; if ($from !== '' && $row['started_at'] < $from) continue; @@ -107,10 +115,13 @@ class SupplierBookkeepingPageData if ($filters['reason'] !== '' && $row['bookkeeping_reason'] !== $filters['reason'] && $row['reason_level_1'] !== $filters['reason']) continue; if ($filters['status'] !== '' && $row['status'] !== $filters['status']) continue; if ($filters['bookkeeping_type'] !== '' && $row['bookkeeping_type'] !== $filters['bookkeeping_type']) continue; - if ($deduction !== '' && $row['deduction_status'] !== $deduction) continue; foreach (self::TEXT_FILTERS as $key => $label) { if ($filters[$key] !== '' && mb_stripos((string) ($row[$key] ?? ''), $filters[$key]) === false) continue 2; } + $matched[] = $row; + } + foreach ($this->valuation->apply($matched) as $row) { + if ($deduction !== '' && $row['deduction_status'] !== $deduction) continue; yield $row; } } diff --git a/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php b/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php index 893a98cc..620ef721 100644 --- a/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php +++ b/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php @@ -53,6 +53,8 @@
待确认{{ number_format($bookkeepingSummary['review']) }}
接入口径

与聚工单「协同工单列表 → 记账」视图一致:工单类型为“记账”;工单状态为待审核、已拒绝、待处理、处理中、已完成(不含已取消);记账原因为“厂家记账”下 12 项与“仓库记账”下 5 项二级原因;发起时间不早于 2026-09-01。同一工单号只取最新一版,状态或原因在聚工单变更后随镜像同步自动更新;多件商品的工单在商品列逐行展示,记账金额只计一次。数据只读,不在此修改。

厂家扣款判定:记账原因为“厂家记账”的,按「厂家记账配置」中供应商的记账方式判定:“正常记账”以及配置表中没有的供应商(含配置不在生效期内)计入扣款,“仅代发仓发错货记账”“不记账”不扣;记账原因为“仓库记账”的,只有订单发货仓库为“某某代发仓”且二级原因为“仓库发错货”时才作为厂家扣款项(供应商配置为“不记账”的除外),其余仓库记账一律不扣。工单未带供应商、或多件商品分属不同供应商且判定不一致的显示“待确认”,不计入扣款金额。

+

小额免记账:“补偿款项”“记账全款”依次用买家实付合计、订单实付金额判断,原记账金额小于50元且原记账金额 ÷ 对应实付小于40%时改为0,备注“小于50且小于0.4,记账改0”。金额等于50元、比例等于40%时不免记账;实付缺失、无效或为0的一轮不自动改0。

+

剩余可扣款工单按原快递单号匹配实际发货;未提供快递单号时按线上订单号匹配。查找期间为工单发起月份及前五个月(如10月取5—10月)。商品成本取普通商品资料成本单价×匹配商品的实际发货数量;“补偿款项”“记账全款”取原记账金额与商品成本+实际运费的较小值,“运费损耗”按实际运费记账。包裹运费只累计一次;发货、数量、成本或运费缺失时标为待确认,不计入扣款合计。原金额及原备注保留,页面、筛选、合计和导出使用同一结果。

@if($bookkeepingError)@endif
diff --git a/tests/Feature/Business/SupplierBookkeepingPageTest.php b/tests/Feature/Business/SupplierBookkeepingPageTest.php index 0fafe593..be26a67e 100644 --- a/tests/Feature/Business/SupplierBookkeepingPageTest.php +++ b/tests/Feature/Business/SupplierBookkeepingPageTest.php @@ -22,12 +22,17 @@ class SupplierBookkeepingPageTest extends FeatureTestCase ], $patch); } - private function mockRepository(array $rows, string $code = 'ready'): void + private function mockRepository(array $rows, string $code = 'ready', bool $realValuation = false): void { $repository = \Mockery::mock(SupplierBookkeepingWorkOrderRepository::class); $repository->shouldReceive('syncStatus')->andReturn(['code' => $code, 'last_succeeded_at' => '2026-10-18 21:00:00']); $repository->shouldReceive('rows')->andReturn($rows); $this->app->instance(SupplierBookkeepingWorkOrderRepository::class, $repository); + if (!$realValuation) { + $valuation = \Mockery::mock(\Modules\EcommerceFinance\Services\SupplierBookkeepingValuation::class); + $valuation->shouldReceive('apply')->andReturnUsing(static fn ($rows) => $rows); + $this->app->instance(\Modules\EcommerceFinance\Services\SupplierBookkeepingValuation::class, $valuation); + } $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); } @@ -95,7 +100,7 @@ class SupplierBookkeepingPageTest extends FeatureTestCase $this->row(['work_order_no' => 'GD-NORMAL', 'supplier' => '鑫呈', 'amount' => '100.00']), $this->row(['work_order_no' => 'GD-NONE', 'supplier' => '尊享', 'amount' => '50.00']), $this->row(['work_order_no' => 'GD-DROP-OTHER', 'supplier' => '曹县洪耀', 'amount' => '30.00']), - $this->row(['work_order_no' => 'GD-DROP-WRONG', 'supplier' => '曹县洪耀', 'amount' => '20.50', 'reason_level_1' => '仓库记账', + $this->row(['work_order_no' => 'GD-DROP-WRONG', 'supplier' => '曹县洪耀', 'amount' => '20.50', 'actual_paid_amount' => '40.00', 'reason_level_1' => '仓库记账', 'reason_level_2' => '仓库发错货(面单照片,实物照片,条形码照片)', 'bookkeeping_reason' => '仓库记账/仓库发错货(面单照片,实物照片,条形码照片)', 'shipping_warehouse' => '曹县洪耀代发仓']), $this->row(['work_order_no' => 'GD-MAIN-WRONG', 'supplier' => '曹县洪耀', 'amount' => '8.00', 'reason_level_1' => '仓库记账', 'reason_level_2' => '仓库发错货(面单照片,实物照片,条形码照片)', 'bookkeeping_reason' => '仓库记账/仓库发错货(面单照片,实物照片,条形码照片)', 'shipping_warehouse' => '常州主仓']), @@ -128,4 +133,65 @@ class SupplierBookkeepingPageTest extends FeatureTestCase $this->withExceptionHandling()->loginAs()->get($this->url(['deduction' => 'x']))->assertStatus(422); } + + public function test_small_compensation_zeroes_page_totals_and_exports_with_original_amount_and_note(): void + { + $this->mockRepository([ + $this->row(['work_order_no' => 'GD-SMALL', 'amount' => '30.00', 'buyer_paid_amount' => '100', 'remark' => '原备注']), + $this->row(['work_order_no' => 'GD-EQUAL-RATIO', 'amount' => '40.00', 'buyer_paid_amount' => '100', 'actual_paid_amount' => '100.00']), + $this->row(['work_order_no' => 'GD-EQUAL-LIMIT', 'amount' => '50.00', 'buyer_paid_amount' => '200']), + ]); + $response = $this->loginAs()->get($this->url())->assertOk(); + $data = app(\Modules\EcommerceFinance\View\SupplierBookkeepingPageData::class)->page(); + $summary = $data['bookkeepingSummary']; + $this->assertSame(9000, $summary['amount_cents']); + $this->assertSame(9000, $summary['deduction_cents']); + $this->assertSame(2, $summary['deduction_count']); + $row = $data['bookkeepingRows']->getCollection()->firstWhere('work_order_no', 'GD-SMALL'); + $this->assertSame('0.00', $row['amount']); + $this->assertSame('30.00', $row['original_amount']); + $this->assertSame("原备注\n小于50且小于0.4,记账改0", $row['remark']); + $response->assertSee('原记账金额')->assertSee('小于50且小于0.4,记账改0'); + $this->get($this->url(['deduction' => '扣款']))->assertOk()->assertDontSee('GD-SMALL')->assertSee('GD-EQUAL-RATIO'); + $this->get($this->url(['deduction' => '不扣款']))->assertOk()->assertSee('GD-SMALL')->assertDontSee('GD-EQUAL-RATIO'); + $csv = $this->get(route('business.supplier-bookkeeping.export', [ + 'company_id' => $this->company->id, 'columns' => 'work_order_no,amount,original_amount,remark', + ]))->assertOk()->streamedContent(); + $this->assertStringContainsString('GD-SMALL,0.00,30.00,', $csv); + $this->assertStringContainsString('小于50且小于0.4,记账改0', $csv); + } + + public function test_real_valuation_keeps_page_totals_deduction_filter_and_export_consistent(): void + { + $this->mockRepository([ + $this->row(['work_order_no' => 'GD-CAPPED', 'amount' => '100.00', 'product_code' => 'SKU-A', 'tracking_no' => 'EXP-1']), + $this->row(['work_order_no' => 'GD-FREIGHT', 'bookkeeping_type' => '运费损耗', 'amount' => '20.00', 'tracking_no' => 'EXP-1']), + $this->row(['work_order_no' => 'GD-PENDING', 'amount' => '100.00', 'tracking_no' => 'MISSING']), + $this->row(['work_order_no' => 'GD-ROUND-TWO', 'amount' => '30.00', 'buyer_paid_amount' => '50.00', 'actual_paid_amount' => '100.00']), + ], 'ready', true); + $repository = \Mockery::mock(\Modules\EcommerceFinance\Repositories\SupplierBookkeepingValuationRepository::class); + $repository->shouldReceive('shipmentsFor')->andReturn([[ + 'id' => 1, 'source_id' => 10, 'express_no' => 'EXP-1', 'trade_no' => '5127787476014026744', + 'barcode' => 'SKU-A', 'qty' => '2', 'freight' => '8.50', 'ship_time' => '2026-08-01 00:00:00', + ]]); + $repository->shouldReceive('costsFor')->andReturn(['SKU-A' => '20.00']); + $this->app->instance(\Modules\EcommerceFinance\Repositories\SupplierBookkeepingValuationRepository::class, $repository); + + $this->loginAs()->get($this->url())->assertOk()->assertSee('GD-CAPPED')->assertSee('GD-PENDING'); + $data = app(\Modules\EcommerceFinance\View\SupplierBookkeepingPageData::class)->page(); + $this->assertSame(5700, $data['bookkeepingSummary']['deduction_cents']); + $this->assertSame(2, $data['bookkeepingSummary']['deduction_count']); + $this->assertSame(1, $data['bookkeepingSummary']['review']); + $this->get($this->url(['deduction' => '扣款']))->assertOk()->assertSee('GD-CAPPED')->assertSee('GD-FREIGHT') + ->assertDontSee('GD-PENDING')->assertDontSee('GD-ROUND-TWO'); + $this->get($this->url(['deduction' => '待确认']))->assertOk()->assertSee('GD-PENDING')->assertDontSee('GD-CAPPED'); + $csv = $this->get(route('business.supplier-bookkeeping.export', [ + 'company_id' => $this->company->id, 'deduction' => '扣款', + 'columns' => 'work_order_no,amount,original_amount,matched_cost,matched_freight,matched_quantity', + ]))->assertOk()->streamedContent(); + $this->assertStringContainsString('GD-CAPPED,48.50,100.00,40.00,8.50,2', $csv); + $this->assertStringContainsString('GD-FREIGHT,8.50,20.00,,8.50,2', $csv); + $this->assertStringNotContainsString('GD-PENDING', $csv); + $this->assertStringNotContainsString('GD-ROUND-TWO', $csv); + } } diff --git a/tests/Unit/EcommerceFinance/SupplierBookkeepingDeductionRuleTest.php b/tests/Unit/EcommerceFinance/SupplierBookkeepingDeductionRuleTest.php index 0af010b6..0245941a 100644 --- a/tests/Unit/EcommerceFinance/SupplierBookkeepingDeductionRuleTest.php +++ b/tests/Unit/EcommerceFinance/SupplierBookkeepingDeductionRuleTest.php @@ -84,4 +84,47 @@ class SupplierBookkeepingDeductionRuleTest extends TestCase $this->assertStringContainsString('甲厂:扣款', $result['deduction_basis']); $this->assertSame("正常记账\n不记账", $result['supplier_config']); } + + public function test_small_compensation_uses_bookkeeping_divided_by_buyer_paid_and_preserves_notes(): void + { + foreach (['补偿款项', '记账全款', '记账全额'] as $type) { + $row = $this->row(['bookkeeping_type' => $type, 'amount' => '30.00', 'buyer_paid_amount' => '100', 'remark' => '协商补偿']); + $result = Rule::decide($row, $this->configs()); + $this->assertSame('0.00', $result['amount']); + $this->assertSame(Rule::STATUS_SKIP, $result['deduction_status']); + $this->assertSame("协商补偿\n小于50且小于0.4,记账改0", $result['remark']); + $this->assertSame('30.00', $row['amount']); + } + $multiple = Rule::decide($this->row(['bookkeeping_type' => '补偿款项', 'amount' => '30.00', 'buyer_paid_amount' => "40\n60"]), []); + $this->assertSame('0.00', $multiple['amount']); + } + + public function test_small_compensation_requires_both_strict_limits_and_complete_positive_paid_amount(): void + { + foreach ([ + ['50.00', '200'], ['49.99', '124.975'], ['40.00', '100'], ['39.99', '99'], + ['30.00', '10'], ['30.00', ''], ['30.00', '0'], ['30.00', '-100'], ['30.00', "100\n"], + ['30.00', "\n100"], ['30.00', 'invalid'], ['0.00', '100'], ['-1.00', '100'], ['', '100'], + ] as [$amount, $paid]) { + $result = Rule::decide($this->row(['bookkeeping_type' => '补偿款项', 'amount' => $amount, 'buyer_paid_amount' => $paid]), $this->configs()); + $this->assertArrayNotHasKey('amount', $result, "$amount / $paid"); + } + $hit = Rule::decide($this->row(['bookkeeping_type' => '补偿款项', 'amount' => '39.99', 'buyer_paid_amount' => '100']), $this->configs()); + $this->assertSame('0.00', $hit['amount']); + $freight = Rule::decide($this->row(['bookkeeping_type' => '运费损耗', 'amount' => '10.00', 'buyer_paid_amount' => '100']), $this->configs()); + $this->assertArrayNotHasKey('amount', $freight); + } + + public function test_second_round_uses_order_actual_paid_when_buyer_paid_round_does_not_hit(): void + { + foreach (['10', '', '0', "100\n"] as $buyer) { + $result = Rule::decide($this->row(['bookkeeping_type' => '补偿款项', 'amount' => '30.00', + 'buyer_paid_amount' => $buyer, 'actual_paid_amount' => '100.00']), $this->configs()); + $this->assertSame('0.00', $result['amount']); + $this->assertSame(Rule::STATUS_SKIP, $result['deduction_status']); + } + $equal = Rule::decide($this->row(['bookkeeping_type' => '记账全款', 'amount' => '40.00', + 'buyer_paid_amount' => '50', 'actual_paid_amount' => '100.00']), $this->configs()); + $this->assertArrayNotHasKey('amount', $equal); + } } diff --git a/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationRepositoryTest.php b/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationRepositoryTest.php new file mode 100644 index 00000000..7beaa03a --- /dev/null +++ b/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationRepositoryTest.php @@ -0,0 +1,89 @@ + 'business_mirror', 'upstream_mirrors.connection' => 'business_mirror']); + // The production dataset key contains dots; configure the full dataset map directly. + config(['upstream_mirrors.datasets' => [ProductCatalogMirrorRepository::DATASET_KEY => [ + 'source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_sku', + 'target' => 'fixture_valuation_skus', 'keys' => ['autoid'], + ]]]); + $schema = Schema::connection('business_mirror'); + if (!$schema->hasTable('jst_oms_shipping')) { + $schema->create('jst_oms_shipping', function (Blueprint $table) { + $table->id(); + $table->integer('source_id'); + foreach (['express_no', 'trade_no', 'barcode', 'qty', 'freight', 'ship_time'] as $column) $table->string($column)->nullable(); + $table->index('express_no'); + $table->index('trade_no'); + }); + } + $schema->create('fixture_valuation_skus', function (Blueprint $table) { + $table->integer('autoid'); + foreach (['_mirror_key', 'sku_id', 'sku_type', 'cost_price'] as $column) $table->string($column)->nullable(); + }); + if (!$schema->hasTable('business_mirror_table_states')) { + $schema->create('business_mirror_table_states', function (Blueprint $table) { + $table->string('dataset_key')->primary(); + $table->dateTime('initial_completed_at')->nullable(); + }); + } + DB::connection('business_mirror')->table('business_mirror_table_states')->updateOrInsert( + ['dataset_key' => ProductCatalogMirrorRepository::DATASET_KEY], ['initial_completed_at' => '2026-10-01 00:00:00']); + } + + public function test_queries_exact_references_and_six_month_dates_without_borrowing_same_order_packages(): void + { + $db = DB::connection('business_mirror'); + foreach ([['EXP-1', '2026-05-01'], ['EXP-1', '2026-04-30'], ['EXP-1', '2026-11-01'], ['EXP-2', '2026-10-20']] as $i => [$tracking, $date]) { + $db->table('jst_oms_shipping')->insert(['source_id' => $i+1, 'express_no' => $tracking, 'trade_no' => 'ORDER-1', + 'barcode' => 'SKU-A', 'qty' => 1, 'freight' => '8.50', 'ship_time' => $date . ' 00:00:00']); + } + $repo = app(Repository::class); + $rows = $repo->shipmentsFor([['started_at' => '2026-10-15', 'tracking_no' => 'EXP-1', 'online_order_no' => 'ORDER-1']]); + $this->assertCount(1, $rows); + $this->assertSame('EXP-1', $rows[0]['express_no']); + $all = $repo->shipmentsFor([['started_at' => '2026-10-15', 'tracking_no' => '', 'online_order_no' => 'ORDER-1']]); + $this->assertCount(2, $all); + $this->assertSame([], $repo->shipmentsFor([['started_at' => '2026-10-15', 'tracking_no' => '', 'online_order_no' => '']])); + } + + public function test_uses_latest_normal_product_cost_and_decodes_sku_plus_without_treating_missing_as_zero(): void + { + DB::connection('business_mirror')->table('fixture_valuation_skus')->insert([ + ['autoid' => 1, '_mirror_key' => '1', 'sku_id' => 'SKU-A', 'sku_type' => 'normal', 'cost_price' => '10.00'], + ['autoid' => 2, '_mirror_key' => '2', 'sku_id' => 'SKU-A', 'sku_type' => 'normal', 'cost_price' => '20.00'], + ['autoid' => 3, '_mirror_key' => '3', 'sku_id' => 'SKU-A', 'sku_type' => 'combination', 'cost_price' => '90.00'], + ['autoid' => 4, '_mirror_key' => '4', 'sku_id' => 'SKU_X002B_B', 'sku_type' => 'normal', 'cost_price' => '15.00'], + ]); + $costs = app(Repository::class)->costsFor(['SKU-A', 'SKU+B', 'MISSING']); + $this->assertSame('20.00', $costs['SKU-A']); + $this->assertSame('15.00', $costs['SKU+B']); + $this->assertArrayNotHasKey('MISSING', $costs); + } + + public function test_latest_cost_wins_when_encoded_sku_variant_falls_in_another_query_batch(): void + { + DB::connection('business_mirror')->table('fixture_valuation_skus')->insert([ + ['autoid' => 1, '_mirror_key' => '1', 'sku_id' => 'SKU+B', 'sku_type' => 'normal', 'cost_price' => '10.00'], + ['autoid' => 2, '_mirror_key' => '2', 'sku_id' => 'SKU_X002B_B', 'sku_type' => 'normal', 'cost_price' => '25.00'], + ]); + // 74 pairs + one uppercase-only numeric SKU place SKU+B at the 150-value boundary. + $skus = array_map(static fn($i) => 'ITEM-'.$i, range(1, 74)); + $skus[] = '123'; + $skus[] = 'SKU+B'; + $this->assertSame('25.00', app(Repository::class)->costsFor($skus)['SKU+B']); + } +} diff --git a/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationTest.php b/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationTest.php new file mode 100644 index 00000000..f27b9d7b --- /dev/null +++ b/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationTest.php @@ -0,0 +1,138 @@ + '2026-10-15 10:00:00', 'bookkeeping_type' => '补偿款项', 'amount' => '100.00', + 'original_amount' => '100.00', 'deduction_status' => '扣款', 'deduction_basis' => '供应商正常记账', + 'remark' => '原备注', 'tracking_no' => 'EXP-1', 'online_order_no' => 'ORDER-1', 'product_code' => 'SKU-A'], $patch); + } + + private function shipment(array $patch = []): array + { + return array_replace(['id' => 1, 'source_id' => 10, 'express_no' => 'EXP-1', 'trade_no' => 'ORDER-1', + 'barcode' => 'SKU-A', 'qty' => '2', 'freight' => '8.50', 'ship_time' => '2026-05-01 00:00:00'], $patch); + } + + private function service(array $shipments, array $costs = ['SKU-A' => '20.00']): SupplierBookkeepingValuation + { + $repository = $this->createMock(Repository::class); + $repository->method('shipmentsFor')->willReturn($shipments); + $repository->method('costsFor')->willReturn($costs); + return new SupplierBookkeepingValuation($repository); + } + + public function test_caps_by_matched_shipped_quantity_times_cost_plus_actual_freight(): void + { + $row = $this->service([$this->shipment()])->apply([$this->row()])[0]; + $this->assertSame('48.50', $row['amount']); + $this->assertSame('100.00', $row['original_amount']); + $this->assertSame('40.00', $row['matched_cost']); + $this->assertSame('8.50', $row['matched_freight']); + $this->assertSame('2', $row['matched_quantity']); + $this->assertSame('2026-05 至 2026-10', $row['valuation_window']); + $this->assertStringStartsWith("原备注\n", $row['remark']); + $this->assertSame('扣款', $row['deduction_status']); + } + + public function test_keeps_lower_or_equal_bookkeeping_amount_and_rounds_ceiling_once(): void + { + $service = $this->service([$this->shipment()]); + foreach (['40.00', '48.50'] as $amount) { + $row = $service->apply([$this->row(['amount' => $amount])])[0]; + $this->assertSame($amount, $row['amount']); + } + $rounded = $this->service([$this->shipment(['freight' => '8.505'])])->apply([$this->row()])[0]; + $this->assertSame('48.51', $rounded['amount']); + } + + public function test_multiple_goods_share_parcel_freight_once_and_all_matched_goods_are_costed(): void + { + $row = $this->service([ + $this->shipment(), + $this->shipment(['id' => 2, 'barcode' => 'SKU-B', 'qty' => 3, 'freight' => null]), + ], ['SKU-A' => '20.00', 'SKU-B' => '5.00'])->apply([$this->row(['product_code' => "SKU-A\nSKU-B"])])[0]; + $this->assertSame('63.50', $row['amount']); + $this->assertSame('55.00', $row['matched_cost']); + $this->assertSame('8.50', $row['matched_freight']); + $this->assertSame('5', $row['matched_quantity']); + } + + public function test_freight_loss_uses_actual_freight_even_above_original_without_requiring_cost(): void + { + $row = $this->service([$this->shipment(['freight' => '80.00'])], []) + ->apply([$this->row(['bookkeeping_type' => '运费损耗', 'amount' => '30.00', 'product_code' => ''])])[0]; + $this->assertSame('80.00', $row['amount']); + $this->assertStringContainsString('运费损耗按原单实际运费记账', $row['remark']); + $originalZero = $this->service([$this->shipment(['freight' => '8.50'])], []) + ->apply([$this->row(['bookkeeping_type' => '运费损耗', 'amount' => '0.00'])])[0]; + $this->assertSame('8.50', $originalZero['amount']); + $this->assertSame('扣款', $originalZero['deduction_status']); + $zero = $this->service([$this->shipment(['freight' => '0.00'])], []) + ->apply([$this->row(['bookkeeping_type' => '运费损耗'])])[0]; + $this->assertSame('0.00', $zero['amount']); + $this->assertSame('不扣款', $zero['deduction_status']); + } + + public function test_exact_tracking_takes_precedence_and_missing_tracking_does_not_borrow_another_parcel(): void + { + $shipments = [$this->shipment(), $this->shipment(['id' => 2, 'source_id' => 11, 'express_no' => 'EXP-2', 'freight' => '20.00'])]; + $service = $this->service($shipments); + $this->assertSame('48.50', $service->apply([$this->row()])[0]['amount']); + $missing = $service->apply([$this->row(['tracking_no' => 'MISSING'])])[0]; + $this->assertSame('待确认', $missing['deduction_status']); + $this->assertSame('100.00', $missing['amount']); + $byOrder = $service->apply([$this->row(['tracking_no' => ''])])[0]; + $this->assertSame('80.00', $byOrder['matched_cost']); + $this->assertSame('28.50', $byOrder['matched_freight']); + $this->assertSame('100.00', $byOrder['amount']); + } + + public function test_window_excludes_april_and_november_and_crosses_years_correctly(): void + { + foreach (['2026-04-30 23:59:59', '2026-11-01 00:00:00'] as $date) { + $row = $this->service([$this->shipment(['ship_time' => $date])])->apply([$this->row()])[0]; + $this->assertSame('待确认', $row['deduction_status']); + } + $this->assertSame(['2025-09-01 00:00:00', '2026-03-01 00:00:00'], Repository::window(['started_at' => '2026-02-28'])); + } + + public function test_missing_cost_quantity_freight_or_goods_stays_pending_instead_of_silently_zeroing(): void + { + foreach ([ + [[$this->shipment()], []], [[$this->shipment()], ['SKU-A' => '0']], + [[$this->shipment(['qty' => null])], ['SKU-A' => '20']], + [[$this->shipment(['freight' => null])], ['SKU-A' => '20']], + [[$this->shipment(['freight' => '-1'])], ['SKU-A' => '20']], + [[$this->shipment(['barcode' => 'SKU-B'])], ['SKU-A' => '20']], + ] as [$shipments, $costs]) { + $row = $this->service($shipments, $costs)->apply([$this->row()])[0]; + $this->assertSame('待确认', $row['deduction_status']); + $this->assertSame('100.00', $row['amount']); + } + } + + public function test_supplier_or_warehouse_exclusions_and_small_zeros_are_never_overridden(): void + { + $repository = $this->createMock(Repository::class); + $repository->expects($this->never())->method('shipmentsFor'); + $service = new SupplierBookkeepingValuation($repository); + $rows = [$this->row(['deduction_status' => '不扣款']), $this->row(['deduction_status' => '待确认']), + $this->row(['deduction_status' => '不扣款', 'amount' => '0.00'])]; + $this->assertSame($rows, $service->apply($rows)); + } + + public function test_partial_multi_tracking_match_is_pending_and_cannot_deduct_only_one_package(): void + { + $row = $this->service([$this->shipment()])->apply([$this->row(['tracking_no' => "EXP-1\nEXP-2"])])[0]; + $this->assertSame('待确认', $row['deduction_status']); + $this->assertSame('100.00', $row['amount']); + } +} diff --git a/tests/Unit/EcommerceFinance/SupplierBookkeepingWorkOrderRepositoryTest.php b/tests/Unit/EcommerceFinance/SupplierBookkeepingWorkOrderRepositoryTest.php index 967fa37b..97f983f4 100644 --- a/tests/Unit/EcommerceFinance/SupplierBookkeepingWorkOrderRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/SupplierBookkeepingWorkOrderRepositoryTest.php @@ -162,4 +162,19 @@ class SupplierBookkeepingWorkOrderRepositoryTest extends TestCase $this->assertSame(['GD-2'], array_column(iterator_to_array($page->rows(['started_from' => '2026-09-10', 'supplier' => '胜'] + $filters, $rows), false), 'work_order_no')); $this->assertSame([], iterator_to_array($page->rows(['started_to' => '2026-09-01'] + $filters, $rows), false)); } + + public function test_keeps_item_alignment_when_a_buyer_paid_amount_is_missing(): void + { + $this->state(); + $this->order(1, 'GD-MULTI', [ + self::REASON => '厂家记账/缺货损失', + 'itemInfo' => [ + ['skuId' => 'SKU-A', 'qty' => 1, 'buyerPaidAmount' => 100], + ['skuId' => 'SKU-B', 'qty' => 2], + ], + ]); + $row = $this->repository()->rows()[0]; + $this->assertSame("100\n", $row['buyer_paid_amount']); + $this->assertSame("SKU-A\nSKU-B", $row['product_code']); + } }