shipping->syncStatus(); } /** * 发货数据匹配窗口:报销月份的上月 1 日 00:00 至下月 1 日 00:00。 * * @return array{from:string,to:string} */ public static function shippingWindow(string $month): array { PlantingReimbursementRepository::window($month); $start = CarbonImmutable::createFromFormat('!Y-m', $month); return [ 'from' => $start->subMonthNoOverflow()->format('Y-m-d H:i:s'), 'to' => $start->addMonthNoOverflow()->format('Y-m-d H:i:s'), ]; } /** 例:2026-09 → “2026-08~2026-09”。 */ public static function shippingLabel(string $month): string { $window = self::shippingWindow($month); return substr($window['from'], 0, 7) . '~' . $month; } /** 命中的免扣字样(拍A发B / 拍A发A),未命中返回 null。全角、大小写、空格均兼容。 */ public static function exemptionKeyword(string ...$remarks): ?string { foreach ($remarks as $remark) { $text = mb_strtoupper((string) preg_replace('/\s+/u', '', mb_convert_kana($remark, 'as'))); if (preg_match('/拍A发([AB])/u', $text, $match) === 1) { return '拍A发' . $match[1]; } } return null; } public static function isFreeGoods(string $barcode, string $productName): bool { foreach (self::FREE_GOODS_KEYWORDS as $keyword) { if (str_contains($productName, $keyword) || str_contains($barcode, $keyword)) { return true; } } return false; } public static function isVirtualOrderItem(string $barcode, string $productName): bool { $code = strtoupper(trim($barcode)); // 透明挂钩条形码以 GG 开头(如 GG003-SZ / GG-xxx),2026-10-20 财务确认。 if (in_array($code, self::VIRTUAL_ORDER_BARCODES, true) || str_starts_with($code, 'GG')) { return true; } foreach (self::VIRTUAL_ORDER_KEYWORDS as $keyword) { if (str_contains($productName, $keyword)) { return true; } } return false; } /** 成本价(元,字符串)× 数量 → 分。 */ public static function deductionCents(string $costPrice, int $qty): int { return (int) round((float) $costPrice * 100 * $qty); } /** * 当月全部种菜报销及其发货匹配、扣款判定(含未匹配,页面按结果筛选)。 * * @return list> */ public function rows(string $month): array { $key = 'planting-loss:rows:v3:' . $month; $cached = Cache::get($key); if (is_array($cached)) { return $cached; } $reimbursements = $this->reimbursements->rows($month); $window = self::shippingWindow($month); $shipments = $this->shipments(array_column($reimbursements, 'online_order_no'), $window['from'], $window['to']); $costs = $this->costPrices(array_merge(...array_map( static fn (array $shipment): array => array_column($shipment['lines'], 'barcode'), array_values($shipments), ) ?: [[]])); $rows = []; foreach ($reimbursements as $row) { $rows[] = $this->judge($row, $shipments[$row['online_order_no']] ?? null, $costs, $month); } try { Cache::put($key, $rows, self::CACHE_SECONDS); } catch (\Throwable $exception) { report($exception); } return $rows; } /** * @param array $row * @param array|null $shipment * @param array $costs 条码 => 成本价 * @return array */ private function judge(array $row, ?array $shipment, array $costs, string $month): array { $deductLines = []; $missingCost = []; $cents = 0; $virtualOrder = false; foreach ($shipment['lines'] ?? [] as $line) { if (self::isVirtualOrderItem($line['barcode'], $line['product_name'])) { $virtualOrder = true; } } foreach ($virtualOrder ? [] : ($shipment['lines'] ?? []) as $line) { if (self::isFreeGoods($line['barcode'], $line['product_name'])) { continue; } $cost = $costs[$line['barcode']] ?? null; if ($cost === null) { $missingCost[] = $line['barcode'] !== '' ? $line['barcode'] : '(无条码)'; $deductLines[] = $line['barcode'] . ' × ' . $line['qty'] . ' 成本价缺失'; continue; } $lineCents = self::deductionCents($cost, $line['qty']); $cents += $lineCents; $deductLines[] = sprintf('%s × %d × %s = %s', $line['barcode'], $line['qty'], $cost, number_format($lineCents / 100, 2, '.', '')); } $freight = $shipment['freight'] ?? null; if ($deductLines !== []) { if ($freight === null) { $deductLines[] = '运费缺失'; } else { $cents += (int) round((float) $freight * 100); $deductLines[] = '运费 ' . $freight; } } $missing = array_values(array_unique($missingCost)); $missingText = implode(';', array_filter([ $missing === [] ? '' : '成本价缺失:' . implode('、', $missing), $deductLines !== [] && $freight === null ? '运费缺失' : '', ])); // 拍A发B/拍A发A 不论是否匹配到发货都单独列出发给运营(2026-10-20 财务确认);匹配发货只用于挑出真实发货的单。 $keyword = self::exemptionKeyword((string) $row['task_remark'], (string) $row['seller_remark']); [$result, $basis] = match (true) { $keyword !== null => [self::RESULT_EXEMPT, '备注含“' . $keyword . '”' . ($shipment === null ? '(未匹配到发货)' : '')], $shipment === null => [self::RESULT_UNMATCHED, self::shippingLabel($month) . ' 发货数据中无此交易单号'], $virtualOrder => [self::RESULT_FREE_GOODS, '发货含信誉订单虚拟条码/透明挂钩,整单不扣款'], $deductLines === [] => [self::RESULT_FREE_GOODS, '只发了虚拟条码/透明挂钩/无实物商品'], $missingText !== '' => [self::RESULT_DEDUCT, $missingText . ',扣款金额未含缺失部分'], default => [self::RESULT_DEDUCT, '已匹配发货,按成本价×数量+运费扣款'], }; $row['loss_result'] = $result; $row['loss_basis'] = $basis; $row['deduction_amount'] = $result === self::RESULT_DEDUCT ? number_format($cents / 100, 2, '.', '') : null; $row['deduction_detail'] = $result === self::RESULT_DEDUCT ? implode("\n", $deductLines) : ''; $row['cost_missing'] = $result === self::RESULT_DEDUCT && $missingText !== ''; $row['shipped_at'] = $shipment['shipped_at'] ?? ''; $row['express_no'] = $shipment['express_no'] ?? ''; $row['warehouse_name'] = $shipment['warehouse_name'] ?? ''; $row['shipped_barcode'] = implode("\n", array_column($shipment['lines'] ?? [], 'barcode')); $row['shipped_product_name'] = implode("\n", array_column($shipment['lines'] ?? [], 'product_name')); $row['shipped_qty'] = $shipment === null ? '' : (string) array_sum(array_column($shipment['lines'], 'qty')); $row['shipped_amount'] = $shipment['amount'] ?? null; $row['shipped_freight'] = $shipment['freight'] ?? null; return $row; } /** * @param list $tradeNos * @return array}> */ private function shipments(array $tradeNos, string $from, string $to): array { $tradeNos = array_values(array_unique(array_filter(array_map('trim', $tradeNos), static fn (string $v): bool => $v !== ''))); $db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local')); $acc = []; foreach (array_chunk($tradeNos, self::CHUNK) as $chunk) { $records = $db->table('jst_oms_shipping') ->whereIn('trade_no', $chunk) ->where('ship_time', '>=', $from) ->where('ship_time', '<', $to) ->orderBy('id') ->get(['trade_no', 'ship_time', 'express_no', 'warehouse_name', 'barcode', 'product_name', 'qty', 'amount', 'freight']); foreach ($records as $record) { $trade = (string) $record->trade_no; $item = &$acc[$trade]; $shipTime = substr((string) $record->ship_time, 0, 19); $item['shipped_at'] = isset($item['shipped_at']) ? min($item['shipped_at'], $shipTime) : $shipTime; foreach (['express_no', 'warehouse_name'] as $key) { $value = trim((string) $record->{$key}); if ($value !== '') { $item[$key][$value] = true; } } foreach (['amount', 'freight'] as $key) { if ($record->{$key} !== null) { $item[$key] = ($item[$key] ?? 0) + (int) round((float) $record->{$key} * 100); } } $barcode = trim((string) $record->barcode); $lineKey = $barcode . '|' . trim((string) $record->product_name); $item['lines'][$lineKey] ??= ['barcode' => $barcode, 'product_name' => trim((string) $record->product_name), 'qty' => 0]; $item['lines'][$lineKey]['qty'] += max(1, (int) $record->qty); unset($item); } } $result = []; foreach ($acc as $trade => $item) { $result[$trade] = [ 'shipped_at' => $item['shipped_at'], 'express_no' => implode("\n", array_keys($item['express_no'] ?? [])), 'warehouse_name' => implode("\n", array_keys($item['warehouse_name'] ?? [])), 'amount' => isset($item['amount']) ? number_format($item['amount'] / 100, 2, '.', '') : null, 'freight' => isset($item['freight']) ? number_format($item['freight'] / 100, 2, '.', '') : null, 'lines' => array_values($item['lines']), ]; } return $result; } /** * 普通商品资料的成本价:条形码(商品编码)= ops_jst_sku.sku_id,同编码取最新一条。 * * @param list $barcodes * @return array */ private function costPrices(array $barcodes): array { $barcodes = array_values(array_unique(array_filter($barcodes, static fn (string $v): bool => $v !== ''))); if ($barcodes === []) { return []; } $connection = $this->mirror->connection(); $table = $this->datasets->get(self::SKU_DATASET)->targetTable(); $costs = []; foreach (array_chunk($barcodes, self::CHUNK) as $chunk) { $records = $connection->table($table) ->whereIn('sku_id', $chunk) ->where('sku_type', 'normal') ->orderByDesc('autoid') ->get(['sku_id', 'cost_price']); foreach ($records as $record) { $sku = (string) $record->sku_id; if (isset($costs[$sku])) { continue; } // 成本价为空或 0 视为缺失,避免把未维护成本的商品算成 0 元扣款。 if (is_numeric($record->cost_price) && (float) $record->cost_price > 0) { $costs[$sku] = rtrim(rtrim(number_format((float) $record->cost_price, 4, '.', ''), '0'), '.'); } } } return $costs; } }