id ?? 0); // 入账前净余额:决定结账单线上/线下及 payWay $balanceBeforeRecharge = bcadd((string)($custom->balance ?? '0'), '0', 2); $respond = CustomClass::rechargeBalance($custom, $pool, $shop, $staff, $payWay, $params); $custom = $respond['custom'] ?? $custom; $ghs = $respond['ghs'] ?? $ghs; $customRecharge = $respond['customRecharge'] ?? null; $allocate = self::fifoSettleWithPool($pair, $shop, $staff, $pool, [ 'payWay' => $payWay, 'balanceBeforeRecharge' => $balanceBeforeRecharge, 'rechargeSource' => 'merchant', 'deduct_balance' => false, ]); if (!empty($customRecharge) && !empty($allocate['clear'])) { self::bindRechargeToClear($customRecharge, $respond['ghsRecharge'] ?? null, $allocate, $custom, $ghs); } return $respond; } /** * 线上支付回调入账后的自动销账(CustomRechargeClass::thirdPay 调用)。 * * 场景:花店 App 向供货商线上充值,钱已加到 balance;若客户还有欠款订单,用本次来款按 FIFO 销账。 * 注意:入账(balance += amount)在调用本方法之前已完成,本方法只负责「分配来款销单」,不再扣 balance。 * * @param object $custom 客户(可未加锁,内部会 lockAccountPair) * @param object $ghs 供货商 * @param object $shop 收款供货商门店 * @param string|float $amount 本次线上实付金额(= 资金池大小) * @param object $customRecharge 客户侧充值单 xhCustomRecharge * @param object|null $ghsRecharge 供货商侧充值单 xhGhsRecharge * @param string|null $balanceBeforeRecharge 入账前客户净 balance,用于判断结账单标「线上付」还是「余额付」 * @return array 销账结果:clear 结账单、realClearAmount 实销总额、clearId/clearSn 等;无欠款时 clear=null */ public static function onlinePayFifoClear($custom, $ghs, $shop, $amount, $customRecharge, $ghsRecharge = null, $balanceBeforeRecharge = null) { // ① 加锁并校验客户/供货商 balance 一致,同时 ensure 历史挂账已并入净余额 $pair = self::lockAccountPair($custom, $ghs); // ② 资金池 = 本次充值金额(两位小数) $pool = self::money($amount); // ③ 作废该客户未付款的待结账单,防止与本次自动销账冲突 self::voidAwaitPayClearBills($pair['custom']->id ?? 0); // ④ 从充值单取支付方式、第三方流水号,供结账单标记 $payWay = intval(is_object($customRecharge) ? ($customRecharge->payWay ?? dict::getDict('payWay', 'unPay')) : dict::getDict('payWay', 'unPay')); $thirdNo = is_object($customRecharge) ? (string)($customRecharge->returnCode ?? '') : ''; // ⑤ 核心:用资金池 FIFO 销挂账,生成 xhClear;deduct_balance=false 表示不再次扣 balance $allocate = self::fifoSettleWithPool($pair, $shop, null, $pool, [ 'payWay' => $payWay, 'thirdNo' => $thirdNo, 'balanceBeforeRecharge' => $balanceBeforeRecharge, 'rechargeSource' => 'online', // 标识 hdApp 线上充值,影响结账单 onlinePay 判定 'deduct_balance' => false, ]); // ⑥ 若确实销了账,把结账单 id/单号/金额写回充值单与余额流水,便于对账 if (!empty($customRecharge) && !empty($allocate['clear'])) { self::bindRechargeToClear($customRecharge, $ghsRecharge, $allocate, $pair['custom'], $pair['ghs']); } return $allocate; } /** 待付结账单 — 客户已转账:来款入账 + 按本单销订单(OrderClearController) */ public static function confirmClearBillWithIncoming($custom, $ghs, $shop, $staff, $clear, $payWay, $customRecharge = null) { $pair = self::lockAccountPair($custom, $ghs); $custom = $pair['custom']; $ghs = $pair['ghs']; $pool = self::money($clear->actPrice ?? 0); if (bccomp($pool, '0', 2) <= 0) { util::fail('结账单金额有误'); } $clearId = intval($clear->id ?? 0); $clearSn = $clear->orderSn ?? ''; $clearAudit = [ 'clearId' => $clearId, 'clearSn' => $clearSn, 'clearAmount' => $pool, ]; $ghsRecharge = null; if (empty($customRecharge)) { $respond = CustomClass::rechargeBalance($custom, $pool, $shop, $staff, $payWay, [ 'remark' => $clear->remark ?? '', 'clearAudit' => $clearAudit, ]); $custom = $respond['custom']; $ghs = $respond['ghs']; $customRecharge = $respond['customRecharge'] ?? null; $ghsRecharge = $respond['ghsRecharge'] ?? null; } // 商家「线下已转账」确认:微信/支付宝亦为线下,不得被 confirmClear 兜底标为线上 OrderClearClass::applyNetBalanceClear($clear, $payWay, [ 'skipCashMoney' => true, 'onlinePay' => intval(dict::getDict('onlinePay', 'not')), ]); $settled = self::money(OrderClearClass::sumClearAmountByClearId($clearId)); if (bccomp($settled, $pool, 2) > 0) { $settled = $pool; } $clearAudit['clearAmount'] = $settled; self::refreshDebtFlags($custom, $ghs); $allocate = [ 'clear' => $clear, 'realClearAmount' => $settled, 'clearId' => $clearId, 'clearSn' => $clearSn, 'clearAmount' => $settled, ]; if (!empty($customRecharge)) { self::bindRechargeToClear($customRecharge, $ghsRecharge, $allocate, $custom, $ghs); } else { self::bindClearToLatestIncomeRow($custom, $ghs, $clear, $settled); } return [ 'custom' => $custom, 'ghs' => $ghs, 'customRecharge' => $customRecharge, 'allocate' => $allocate, ]; } /** 用当前正余额 FIFO 销挂账(原 useBalanceClear) */ public static function consumePositiveBalanceFifo($custom, $ghs, $shop, $staff) { $pair = self::lockAccountPair($custom, $ghs); $pool = self::money($pair['custom']->balance ?? 0); if (bccomp($pool, '0', 2) <= 0) { return true; } $balanceBeforeRecharge = self::money($pair['custom']->balance ?? 0); $allocate = self::fifoSettleWithPool($pair, $shop, $staff, $pool, [ 'deduct_balance' => true, 'balanceBeforeRecharge' => $balanceBeforeRecharge, 'rechargeSource' => 'merchant', ]); return true; } /** * 制定 FIFO 销账计划:不真正写库,只算出「资金池能销哪些订单、各销多少」。 * * 规则:查 customId 下 debt=1 的订单,按 id 升序;每笔取 min(订单剩余欠款, 池子剩余),直到池子用完。 * * @param int $customId 客户 id * @param string|float $poolAmount 可用资金池金额 * @return array 计划列表,空数组表示无挂账或池子为 0 */ public static function planFifoByPool($customId, $poolAmount) { $pool = self::money($poolAmount); if (bccomp($pool, '0', 2) <= 0) { return []; } // 只处理仍标记为欠款(debt=1)的订单,id 小=下单早,先销 $orderList = OrderClass::getAllByCondition(['customId' => $customId, 'debt' => 1], 'id asc', '*', null, true); if (empty($orderList)) { return []; } $plan = []; $used = '0.00'; // 已分配进计划的金额 foreach ($orderList as $order) { $left = bcsub($pool, $used, 2); if (bccomp($left, '0', 2) <= 0) { break; // 资金池已分完 } $remain = self::money($order->remainDebtPrice ?? 0); if (bccomp($remain, '0', 2) <= 0) { continue; // 该单已无剩余欠款,跳过 } // 本单销账额 = min(本单欠款, 池子剩余) $clearAmount = bccomp($remain, $left, 2) <= 0 ? $remain : $left; $plan[] = [ 'orderId' => (int)($order->id ?? 0), 'clearAmount' => $clearAmount, 'orderSn' => $order->orderSn ?? '', ]; $used = bcadd($used, $clearAmount, 2); } return $plan; } /** * 用资金池执行 FIFO 销账并生成结账单(merchant/online/余额销账共用内核)。 * * 流程:planFifoByPool → 无计划则直接返回 → 创建 xhClear → applyNetBalanceClear 更新订单欠款 → * 可选 deduct_balance 从 balance 扣实销额 → refreshDebtFlags 刷新 isDebt。 * * @param array{custom:object, ghs:object} $pair lockAccountPair 返回的客户/供货商 * @param object $shop 操作门店 * @param object|null $staff 操作员工(线上回调无员工传 null) * @param string|float $poolAmount 资金池 * @param array $options payWay/thirdNo/balanceBeforeRecharge/rechargeSource/deduct_balance/onlinePay 等 * @return array clear、realClearAmount、clearId、clearSn、custom、ghs */ protected static function fifoSettleWithPool(array $pair, $shop, $staff, $poolAmount, $options = []) { $custom = $pair['custom']; $ghs = $pair['ghs']; $customId = $custom->id ?? 0; $pool = self::money($poolAmount); $plan = self::planFifoByPool($customId, $pool); if (empty($plan)) { // 没有可销订单:只刷新欠款标记,不生成结账单 self::refreshDebtFlags($custom, $ghs); return self::emptyAllocateResult($custom, $ghs); } $planTotal = self::sumPlan($plan); if (bccomp($planTotal, $pool, 2) > 0) { util::fail('销账计划超过本次可用金额,请联系技术员'); } self::voidAwaitPayClearBills($customId); $staffId = $staff->id ?? 0; $staffName = $staff->name ?? ''; $sjId = $shop->sjId ?? 0; $shopId = $shop->id ?? 0; $actualPayWay = isset($options['payWay']) && is_numeric($options['payWay']) ? intval($options['payWay']) : intval(dict::getDict('payWay', 'unPay')); // 决定结账单上的 onlinePay、payWay(线上纯来款 vs 掺杂历史余额 vs 纯余额销账) if (array_key_exists('balanceBeforeRecharge', $options)) { $payMeta = self::resolveClearPayMetaForRechargeFifo( $options['balanceBeforeRecharge'], $planTotal, $pool, $actualPayWay, $options['rechargeSource'] ?? 'merchant', !empty($options['deduct_balance']) ); $payWay = $payMeta['payWay']; $onlinePay = $payMeta['onlinePay']; } elseif (isset($options['onlinePay']) && is_numeric($options['onlinePay'])) { $payWay = $actualPayWay; $onlinePay = intval($options['onlinePay']); } else { $payWay = $actualPayWay; $onlinePay = self::resolveOnlinePayByPayWay($payWay); } // 按 plan 创建结账单 xhClear,并关联各订单销账明细 $clear = OrderClearClass::clearWithAmountMap([ 'customId' => $customId, 'ghsShopAdminId' => $staffId, 'ghsShopId' => $shopId, 'ghsShopAdminName' => $staffName, 'modifyPrice' => $planTotal, 'payWay' => $payWay, 'onlinePay' => $onlinePay, 'complete' => 0, 'remark' => '', ], $plan, $sjId, $shopId); // 更新订单 remainDebtPrice、净 balance 等(充值链路 skipCashMoney,不再动门店现金) OrderClearClass::applyNetBalanceClear($clear, $payWay, [ 'skipCashMoney' => true, 'onlinePay' => $onlinePay, ]); // 线上支付:把拉卡拉/微信流水号写入结账单 thirdNo $clearId = intval($clear->id ?? 0); if ($clearId > 0 && !empty($options['thirdNo'])) { $clear = OrderClearClass::getLockById($clearId); if (!empty($clear) && self::modelHasAttr($clear, 'thirdNo')) { $clear->thirdNo = (string)$options['thirdNo']; $clear->save(false, ['thirdNo']); } } // 校验:库内实销合计必须与计划一致,且不能超过资金池 $settled = self::money(OrderClearClass::sumClearAmountByClearId($clear->id ?? 0)); if (bccomp($settled, $planTotal, 2) !== 0) { util::fail('销账结果与计划不一致,请检查订单待结或联系技术员'); } if (bccomp($settled, $pool, 2) > 0) { util::fail('销账金额超过本次可用金额' . floatval($pool) . '元,请检查订单待结或联系技术员'); } // 「用账面正余额销账」场景:FIFO 完成后要从 balance 扣掉实销额(线上充值场景 deduct_balance=false 不扣) if (!empty($options['deduct_balance']) && bccomp($settled, '0', 2) > 0) { $custom = CustomClass::getLockById($custom->id ?? 0); $ghs = BizGhsClass::getLockById($ghs->id ?? 0); CustomClass::clearConsumeBalance($settled, $custom, $ghs, $shop, $staff, $clear); } self::refreshDebtFlags($custom, $ghs); return [ 'clear' => $clear, 'realClearAmount' => $settled, 'clearId' => $clear->id ?? 0, 'clearSn' => $clear->orderSn ?? '', 'clearAmount' => $settled, 'custom' => $custom, 'ghs' => $ghs, ]; } /** * 加锁客户与供货商账户,并校验两端 balance 必须相等。 * * 副作用:ensureCustomMoneyReady / ensureGhsMoneyReady 可能把历史挂账并入净 balance(幂等)。 * * @param object|array $custom 客户或含 id 的数组 * @param object|null $ghs 供货商,空则按 custom.ghsId 查 * @return array{custom: object, ghs: object} */ public static function lockAccountPair($custom, $ghs = null) { if (empty($custom)) { util::fail('没有客户信息'); } $customId = is_object($custom) ? ($custom->id ?? 0) : ($custom['id'] ?? 0); $custom = CustomClass::getLockById($customId); if (empty($custom)) { util::fail('没有找到客户'); } AccountMoneyClass::ensureCustomMoneyReady($custom, true); $ghsId = $custom->ghsId ?? 0; if (empty($ghs)) { $ghs = BizGhsClass::getLockById($ghsId); } if (empty($ghs)) { util::fail('没有找到供货商'); } AccountMoneyClass::ensureGhsMoneyReady($ghs, true); if (bccomp(self::money($custom->balance), self::money($ghs->balance), 2) !== 0) { noticeUtil::push("客户的余额,和供货商端不一致,客户 ID:" . $custom->id . " 余额:" . $custom->balance . " 供货商 ID:" . $ghsId . ' 余额:' . $ghs->balance, '15280215347'); util::fail('余额有问题,请联系管理员'); } return ['custom' => $custom, 'ghs' => $ghs]; } /** * 作废客户名下「待付款」状态的结账单。 * 为什么:新来款/新销账前清掉旧草稿单,避免客户重复确认或数据打架。 */ protected static function voidAwaitPayClearBills($customId) { OrderClearClass::expireAwaitPayClears(intval($customId)); } /** 汇总 FIFO 计划里各订单 clearAmount 之和 */ protected static function sumPlan(array $plan) { $total = '0.00'; foreach ($plan as $row) { $total = bcadd($total, self::money($row['clearAmount'] ?? 0), 2); } return $total; } /** * 按当前净 balance 刷新客户 isDebt、供货商 debt 标记。 * balance < 0 → 仍欠款;balance >= 0 → 不欠款。 */ protected static function refreshDebtFlags($custom, $ghs) { $bal = self::money($custom->balance ?? 0); $custom->isDebt = bccomp($bal, '0', 2) < 0 ? CustomClass::IS_DEBT_YES : CustomClass::IS_DEBT_NO; $custom->save(false, ['isDebt']); if (!empty($ghs)) { $ghs->debt = bccomp($bal, '0', 2) < 0 ? 2 : 1; $ghs->save(false, ['debt']); } } /** 无挂账可销时的空结果,clear=null、金额为 0 */ protected static function emptyAllocateResult($custom, $ghs) { return [ 'clear' => null, 'realClearAmount' => '0.00', 'clearId' => 0, 'clearSn' => '', 'clearAmount' => '0.00', 'custom' => $custom, 'ghs' => $ghs, ]; } /** * 充值销账成功后,把结账单信息回写到充值单 + 余额变动流水。 * * 写哪些表: * - xhCustomRecharge / xhGhsRecharge:clearId、clearSn、clearAmount、balance * - xhCustomBalanceChange / xhGhsBalanceChange:同 relateId 的入账流水补 clear 字段 */ protected static function bindRechargeToClear($customRecharge, $ghsRecharge, $allocate, $custom = null, $ghs = null) { $clearId = $allocate['clearId'] ?? 0; $clearSn = $allocate['clearSn'] ?? ''; $clearAmount = $allocate['clearAmount'] ?? '0.00'; $finalCustomBalance = is_object($custom) ? ($custom->balance ?? null) : null; $finalGhsBalance = is_object($ghs) ? ($ghs->balance ?? null) : null; // 充值单上记录「这笔钱销了哪张结账单、销了多少」 if (!empty($customRecharge) && self::modelHasAttr($customRecharge, 'clearId')) { $customRecharge->clearId = $clearId; $customRecharge->clearSn = $clearSn; $customRecharge->clearAmount = $clearAmount; if ($finalCustomBalance !== null) { $customRecharge->balance = $finalCustomBalance; } $customRecharge->save(false); } $relateId = $customRecharge->id ?? 0; $customId = is_object($custom) ? intval($custom->id ?? 0) : 0; if ($customId <= 0 && is_object($customRecharge)) { $customId = intval($customRecharge->customId ?? 0); } // 客户侧余额流水补结账单关联 CustomBalanceChangeClass::patchClearAuditByRechargeId($customId, $relateId, $clearId, $clearSn, $clearAmount, $finalCustomBalance); self::patchBalanceChangeRow(CustomBalanceChangeClass::class, $relateId, $clearId, $clearSn, $clearAmount, [ 'customId' => $customId, ], $finalCustomBalance); if (!empty($ghsRecharge)) { $gRelateId = intval($ghsRecharge->id ?? 0); CustomBalanceChangeClass::patchClearAuditByGhsRechargeId( $gRelateId, $clearId, $clearSn, $clearAmount, $finalGhsBalance ); self::patchBalanceChangeRow(GhsBalanceChangeClass::class, $gRelateId, $clearId, $clearSn, $clearAmount, [], $finalGhsBalance); $balanceGhsId = intval($ghsRecharge->ghsId ?? 0); if ($balanceGhsId > 0) { // 供货商侧可能有多条同金额入账流水,按 ghsId+amount 再 patch 一条 self::patchBalanceChangeRow(GhsBalanceChangeClass::class, 0, $clearId, $clearSn, $clearAmount, [ 'ghsId' => $balanceGhsId, 'amount' => self::money($ghsRecharge->amount ?? 0), ], $finalGhsBalance); } if ($finalGhsBalance !== null) { $ghsRecharge->balance = $finalGhsBalance; $ghsRecharge->save(false, ['balance']); } } } /** * 无充值单、仅确认结账单时:把 clear 信息 patch 到最近一条入账流水(客户+供货商各一条)。 */ protected static function bindClearToLatestIncomeRow($custom, $ghs, $clear, $clearAmount) { $clearId = $clear->id ?? 0; $clearSn = $clear->orderSn ?? ''; self::patchBalanceChangeRow(CustomBalanceChangeClass::class, 0, $clearId, $clearSn, $clearAmount, [ 'customId' => $custom->id ?? 0, 'io' => 1, ]); self::patchBalanceChangeRow(GhsBalanceChangeClass::class, 0, $clearId, $clearSn, $clearAmount, [ 'ghsId' => $ghs->id ?? 0, 'io' => 1, ]); } /** * 找最近一条匹配的余额变动流水,补上 clearId/clearSn/clearAmount。 * * @param string $class CustomBalanceChangeClass 或 GhsBalanceChangeClass * @param int $relateId 充值单 id,0 表示不按 relateId 筛 * @param array $extraWhere 额外条件如 customId、ghsId、amount */ protected static function patchBalanceChangeRow($class, $relateId, $clearId, $clearSn, $clearAmount, $extraWhere = [], $finalBalance = null) { $where = array_merge(['io' => 1], $extraWhere); // io=1 只 patch 入账流水 if ($relateId > 0) { $where['relateId'] = $relateId; } $row = $class::getByCondition($where, true, null, 'id DESC'); if (empty($row) || !self::modelHasAttr($row, 'clearId')) { return; } $row->clearId = $clearId; $row->clearSn = $clearSn; $row->clearAmount = $clearAmount; if (self::modelHasAttr($row, 'giveAmount')) { $row->giveAmount = '0.00'; } if ($finalBalance !== null && self::modelHasAttr($row, 'balance')) { $row->balance = $finalBalance; } $row->save(false); } /** 模型是否有某字段(兼容未迁移库) */ protected static function modelHasAttr($row, $attr) { return is_object($row) && method_exists($row, 'hasAttribute') && $row->hasAttribute($attr); } /** 金额统一保留两位小数的字符串,避免 float 精度问题 */ protected static function money($value) { return bcadd((string)$value, '0', 2); } /** * 按支付方式推断结账单是否线上付:仅微信/支付宝为线上。 * 用于非充值 FIFO 场景的兜底,充值链路走 resolveClearPayMetaForRechargeFifo。 */ protected static function resolveOnlinePayByPayWay($payWay) { $payWay = intval($payWay); $wxPay = intval(dict::getDict('payWay', 'wxPay')); $aliPay = intval(dict::getDict('payWay', 'alipay')); if (in_array($payWay, [$wxPay, $aliPay], true)) { return intval(dict::getDict('onlinePay', 'yes')); } return intval(dict::getDict('onlinePay', 'not')); } /** * 充值 FIFO 销账时,决定 xhClear 上的 onlinePay 与 payWay。 * * 业务规则(简化理解): * | 场景 | onlinePay | payWay | * |------|-----------|--------| * | 用账面余额销账 deduct_balance | 线下 | 2 余额 | * | 充值前已有正余额(掺杂历史余额) | 线下 | 2 余额 | * | hdApp 线上充,充前无正余额,实销≤来款 | 线上 | 微信/支付宝等 | * | ghsApp 商家手动充 | 线下 | 所选方式 | * * @param string|null $balanceBeforeRecharge 入账前净 balance * @param string $settledAmount 计划/实销总额 * @param string $poolAmount 本次资金池(来款额) * @param int $actualPayWay 充值单上的支付方式 * @param string $rechargeSource 'online' | 'merchant' * @param bool $deductBalance 是否「纯余额销账」 * @return array{onlinePay:int, payWay:int} */ protected static function resolveClearPayMetaForRechargeFifo( $balanceBeforeRecharge, $settledAmount, $poolAmount, $actualPayWay, $rechargeSource, $deductBalance = false ) { $notOnline = intval(dict::getDict('onlinePay', 'not')); $yesOnline = intval(dict::getDict('onlinePay', 'yes')); // 纯余额销账:一律线下 + payWay=余额 if ($deductBalance) { return [ 'onlinePay' => $notOnline, 'payWay' => self::CLEAR_PAY_WAY_BALANCE, ]; } $before = bcadd((string)$balanceBeforeRecharge, '0', 2); $settled = bcadd((string)$settledAmount, '0', 2); $pool = bcadd((string)$poolAmount, '0', 2); $actualPayWay = intval($actualPayWay); // 充值前客户已有正余额:本次来款+旧余额一起销,结账单标「余额支付」非纯线上 if (bccomp($before, '0', 2) > 0) { return [ 'onlinePay' => $notOnline, 'payWay' => self::CLEAR_PAY_WAY_BALANCE, ]; } // 无销账或销账超过来款:保守标线下+实付方式 if (bccomp($settled, '0', 2) <= 0 || bccomp($settled, $pool, 2) > 0) { return [ 'onlinePay' => $notOnline, 'payWay' => $actualPayWay, ]; } // hdApp 线上充值且充前无正余额:微信/支付宝可标线上付 if ($rechargeSource === 'online') { $wxPay = intval(dict::getDict('payWay', 'wxPay')); $aliPay = intval(dict::getDict('payWay', 'alipay')); if (in_array($actualPayWay, [$wxPay, $aliPay], true)) { return [ 'onlinePay' => $yesOnline, 'payWay' => $actualPayWay, ]; } return [ 'onlinePay' => $notOnline, 'payWay' => $actualPayWay, ]; } // ghsApp 商家柜台帮充:线下 + 所选支付方式 return [ 'onlinePay' => $notOnline, 'payWay' => $actualPayWay, ]; } }