diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index fbc29035..8ac4a408 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -3342,6 +3342,8 @@ class Pages extends Controller 'preview-statistics' => $this->contentForPreviewStatistics(), 'planting-details' => $this->contentForPlantingDetails(), 'jst-order-labels' => $this->contentForJstOrderLabels(), + 'planting-reimbursements' => app(\Modules\EcommerceFinance\View\PlantingReimbursementPageData::class)->page(), + 'planting-loss' => app(\Modules\EcommerceFinance\View\PlantingLossPageData::class)->page(), 'finance-data' => app(\Modules\EcommerceFinance\View\SupplierReconciliationPageData::class)->financeData(), 'supplier-data' => app(\Modules\EcommerceFinance\View\SupplierReconciliationPageData::class)->supplierData(), 'store-report-data-logic' => $this->prepareStoreReportLogic(), diff --git a/app/Http/Controllers/Business/PlantingWorkOrders.php b/app/Http/Controllers/Business/PlantingWorkOrders.php new file mode 100644 index 00000000..58ce9f73 --- /dev/null +++ b/app/Http/Controllers/Business/PlantingWorkOrders.php @@ -0,0 +1,75 @@ +filters(); + + return $this->csv( + PlantingReimbursementPageData::TITLE . '-' . $filters['month'], + $data->exportColumns(), + static fn () => $data->rows($filters), + ); + } + + public function exportLoss(PlantingLossPageData $data) + { + $filters = $data->filters(); + $suffix = match ($filters['result']) { + PlantingLossCalculator::RESULT_EXEMPT => '-不扣款拍A发B', + PlantingLossCalculator::RESULT_FREE_GOODS => '-不扣款虚拟条码挂钩', + PlantingLossCalculator::RESULT_UNMATCHED => '-未匹配发货', + 'all' => '-全部', + default => '-扣款', + }; + + return $this->csv( + PlantingLossPageData::TITLE . '-' . $filters['month'] . $suffix, + $data->exportColumns(), + static fn () => $data->rows($filters), + ); + } + + /** @param array $columns @param callable():iterable> $rows */ + private function csv(string $name, array $columns, callable $rows) + { + $file = tmpfile(); + abort_if($file === false, 500, '无法创建导出文件。'); + + try { + fwrite($file, "\xEF\xBB\xBF"); + fputcsv($file, array_values($columns)); + foreach ($rows() as $row) { + $values = []; + foreach (array_keys($columns) as $key) { + $values[] = $row[$key] ?? ''; + } + fputcsv($file, array_map(static function (mixed $value): string { + $value = (string) $value; + + // 长数字单号/账号保留文本,避免 Excel 截断;同时阻止公式执行。 + return preg_match('/^[=+\-@\t\r\n]|^\d{12,}$/u', $value) ? "'" . $value : $value; + }, $values)); + } + rewind($file); + } catch (\Throwable $exception) { + fclose($file); + report($exception); + abort(503, '聚工单镜像或发货数据未就绪,未生成不完整的导出文件,请稍后重试。'); + } + + return response()->streamDownload(static function () use ($file): void { + fpassthru($file); + fclose($file); + }, $name . '-' . now()->format('YmdHis') . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8']); + } +} diff --git a/config/business_navigation.php b/config/business_navigation.php index 87ac40fa..1d56c988 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -540,6 +540,8 @@ $navigation = [ 'pages' => [ ['slug' => 'planting-details', 'title' => '种菜明细表'], ['slug' => 'jst-order-labels', 'title' => '聚水潭订单标签明细'], + ['slug' => 'planting-reimbursements', 'title' => '种菜报销'], + ['slug' => 'planting-loss', 'title' => '种菜损耗'], ], ], [ diff --git a/modules/EcommerceFinance/Repositories/PlantingReimbursementRepository.php b/modules/EcommerceFinance/Repositories/PlantingReimbursementRepository.php new file mode 100644 index 00000000..ddb43b19 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/PlantingReimbursementRepository.php @@ -0,0 +1,367 @@ + 'orderField', + 'internal_order_no' => 'o_id', + 'platform' => 'platform', + 'shop_name' => 'shopField', + 'actual_paid_amount' => 'actualPaidAmountField', + 'task_remark' => 'TEXTField1413856620251113150546', + 'seller_remark' => 'businessRemarkField', + 'task_payment' => 'NUMBERField1413856620250924131615', + 'task_freight' => 'NUMBERField1413856620251113150537', + 'task_commission' => 'NUMBERField1413856620251230154457', + 'jingxi_price_diff' => 'NUMBERField1413856620250924131623', + 'task_deduction' => 'NUMBERField1413856620251113150612', + 'task_empty_package_fee' => 'NUMBERField1413856620251113145620', + 'reimbursement_amount' => 'NUMBERField1413856620251117101250', + 'payee_name' => 'TEXTField1413856620251117180931', + 'payee_account' => 'TEXTField1413856620251117113451', + 'payee_bank' => 'TEXTField1413856620251117112729', + 'payee_mobile' => 'TEXTField1413856620260618171005', + 'erp_order_status' => 'erpOrderStatus', + 'platform_order_status' => 'platformOrderStatus', + 'paid_at' => 'payDateField', + ]; + + public const MONEY_FIELDS = [ + 'actual_paid_amount', 'task_payment', 'task_freight', 'task_commission', 'jingxi_price_diff', + 'task_deduction', 'task_empty_package_fee', 'reimbursement_amount', + ]; + + private const ERP_ORDER_STATUS = [ + 'Sent' => '已发货', 'Merged' => '被合并', 'Cancelled' => '已取消', 'Split' => '被拆分', + 'WaitConfirm' => '待审核', 'WaitFConfirm' => '待财审', 'Delivering' => '发货中', + 'Question' => '异常', 'WaitOuterSent' => '等供销商发货', + ]; + + private const ROWS_CACHE_SECONDS = 600; + + private const QUERY_TIMEOUT_SECONDS = 90; + + private readonly ConnectionInterface $connection; + + public function __construct( + private readonly BusinessMirrorDatasetRegistry $datasets, + private readonly GenericBusinessMirrorRepository $mirror, + ) { + $this->connection = $mirror->connection(); + } + + /** + * 报销月份 → 完成时间窗口 [M-02 00:00, M+1-02 00:00)。 + * + * @return array{from:string,to:string} + */ + public static function window(string $month): array + { + if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) !== 1 || $month < self::FIRST_MONTH) { + throw new InvalidArgumentException('报销月份无效。'); + } + $start = CarbonImmutable::createFromFormat('!Y-m-d', $month . '-02'); + + return ['from' => $start->format('Y-m-d H:i:s'), 'to' => $start->addMonthNoOverflow()->format('Y-m-d H:i:s')]; + } + + /** 默认月份:完成时间窗口已开始的最近一个报销月(每月 1 日仍算上月)。 */ + public static function defaultMonth(?CarbonImmutable $now = null): string + { + $now ??= CarbonImmutable::now(); + $month = $now->subDay()->format('Y-m'); + + return max($month, self::FIRST_MONTH); + } + + /** @return list 可选报销月份(新到旧) */ + public static function months(?CarbonImmutable $now = null): array + { + $months = []; + $cursor = CarbonImmutable::createFromFormat('!Y-m', self::defaultMonth($now)); + while ($cursor->format('Y-m') >= self::FIRST_MONTH) { + $months[] = $cursor->format('Y-m'); + $cursor = $cursor->subMonthNoOverflow(); + } + + return $months; + } + + /** @return array{code:string,last_succeeded_at:?string} */ + public function syncStatus(): array + { + try { + $schema = Schema::connection($this->connection->getName()); + if (! $schema->hasTable('business_mirror_table_states') || ! $schema->hasTable($this->dataset()->targetTable())) { + return ['code' => 'unavailable', 'last_succeeded_at' => null]; + } + $state = $this->mirror->state($this->dataset()); + } catch (Throwable) { + return ['code' => 'unavailable', 'last_succeeded_at' => null]; + } + if (! is_array($state)) { + return ['code' => 'unavailable', 'last_succeeded_at' => null]; + } + + $last = isset($state['last_succeeded_at']) ? (string) $state['last_succeeded_at'] : null; + if (($state['initial_completed_at'] ?? null) === null) { + return ['code' => ($state['status'] ?? '') === 'failed' ? 'initial_failed' : 'initializing', 'last_succeeded_at' => $last]; + } + + return ['code' => ($state['status'] ?? '') === 'failed' ? 'failed' : 'ready', 'last_succeeded_at' => $last]; + } + + /** + * 某报销月份的全部种菜报销(已完成,每个工单号取最新一版),按完成时间倒序。 + * 结果按月份缓存 10 分钟;单月约 1.5~2 万行。 + * + * @return list> + */ + public function rows(string $month): array + { + $window = self::window($month); + $status = $this->syncStatus(); + if (! in_array($status['code'], ['ready', 'failed'], true)) { + throw new RuntimeException('business_mirror_dataset_not_ready'); + } + + // 镜像每几分钟增量一次,按版本做键会让缓存几乎每次失效;固定按月份缓存 10 分钟。 + $key = 'planting-reimbursements:rows:v2:' . $month; + $cached = Cache::get($key); + if (is_array($cached)) { + return $cached; + } + + $rows = $this->load($window['from'], $window['to']); + try { + Cache::put($key, $rows, self::ROWS_CACHE_SECONDS); + } catch (Throwable $exception) { + report($exception); + } + + return $rows; + } + + /** @return list> */ + private function load(string $from, string $to): array + { + $table = $this->dataset()->targetTable(); + $query = $this->connection->table($table) + ->where('woTypeId', self::WORK_TYPE_ID) + ->where('completeTime', '>=', $from) + ->where('completeTime', '<', $to) + ->select([ + 'Id', 'woNo', 'status', 'createby', 'executors', 'completer', + 'completeTime', 'created', 'businessFieldList', + ]) + ->orderByDesc('completeTime') + ->orderByDesc('woNo'); + + /** @var array $latest 窗口内每个工单号的最新一版 */ + $latest = []; + foreach ($this->boundedGet($query) as $record) { + $woNo = (string) $record->woNo; + if (! isset($latest[$woNo]) || (int) $record->Id > (int) $latest[$woNo]->Id) { + $latest[$woNo] = $record; + } + } + + // 同一工单若有更新的一版(可能已改完成时间或被作废)落在窗口外,以最新一版为准,这里剔除。 + $newestIds = []; + foreach (array_chunk(array_map('strval', array_keys($latest)), 1000) as $chunk) { + $versions = $this->connection->table($table) + ->where('woTypeId', self::WORK_TYPE_ID) + ->whereIn('woNo', $chunk) + ->groupBy('woNo') + ->selectRaw('woNo, MAX(Id) AS newest_id') + ->get(); + foreach ($versions as $version) { + $newestIds[(string) $version->woNo] = (int) $version->newest_id; + } + } + + $rows = []; + foreach ($latest as $woNo => $record) { + if (($newestIds[(string) $woNo] ?? (int) $record->Id) > (int) $record->Id) { + continue; + } + // 最新一版已不是“已完成”(如完成后被作废)的工单不计入。 + if ((string) $record->status !== self::STATUS_CONFIRMED) { + continue; + } + $rows[] = $this->decorate($record); + } + + return $rows; + } + + /** @return array */ + private function decorate(object $record): array + { + $fields = $this->fields((string) ($record->businessFieldList ?? '')); + $row = [ + 'work_order_no' => (string) $record->woNo, + 'started_at' => $this->time($record->created ?? null), + 'completed_at' => $this->time($record->completeTime ?? null), + 'status' => '已完成', + 'initiator' => (string) ($record->createby ?? ''), + 'executor' => (string) ($record->executors ?? ''), + 'completer' => (string) ($record->completer ?? ''), + ]; + foreach (self::FIELDS as $key => $fieldId) { + $row[$key] = $this->display($fields[$fieldId] ?? null); + } + foreach (self::MONEY_FIELDS as $key) { + $row[$key] = $this->money($row[$key]); + } + $row['erp_order_status'] = self::ERP_ORDER_STATUS[$row['erp_order_status']] ?? $row['erp_order_status']; + + $items = $this->items($fields['itemInfo']['raw'] ?? null); + $row['product_code'] = implode("\n", array_filter(array_column($items, 'skuId'))); + $row['product_name'] = implode("\n", array_filter(array_column($items, 'name'))); + $row['product_quantity'] = implode("\n", array_filter(array_column($items, 'qty'))); + $row['platform_product_id'] = implode("\n", array_filter(array_column($items, 'platformItemId'))); + + return $row; + } + + /** @return array}> */ + private function fields(string $json): array + { + if (trim($json) === '') { + return []; + } + try { + $decoded = json_decode($json, true, 64, JSON_THROW_ON_ERROR); + } catch (Throwable) { + return []; + } + if (isset($decoded['businessField']) && is_array($decoded['businessField'])) { + $decoded = $decoded['businessField']; + } + + $fields = []; + foreach (is_array($decoded) ? $decoded : [] as $item) { + if (is_array($item) && isset($item['fieldId'])) { + $fields[(string) $item['fieldId']] = [ + 'value' => $item['value'] ?? ($item['valueList'] ?? null), + 'label' => $item['valueLabel'] ?? null, + 'raw' => $item, + ]; + } + } + + return $fields; + } + + /** @param array{value:mixed,label:mixed}|null $field */ + private function display(?array $field): string + { + if ($field === null) { + return ''; + } + foreach ([$field['label'], $field['value']] as $value) { + if (is_array($value)) { + $value = implode('、', array_filter(array_map(static fn (mixed $v): string => is_scalar($v) ? trim((string) $v) : '', $value))); + } + if (is_scalar($value) && trim((string) $value) !== '') { + return trim((string) $value); + } + } + + return ''; + } + + /** + * @param array|null $raw + * @return list> + */ + private function items(?array $raw): array + { + $items = []; + foreach ((array) ($raw['childFieldList'] ?? []) as $childRow) { + $item = []; + foreach ((array) $childRow as $child) { + if (is_array($child) && isset($child['fieldId'])) { + $v = $child['valueLabel'] ?? $child['value'] ?? ''; + $item[(string) $child['fieldId']] = is_scalar($v) ? trim((string) $v) : ''; + } + } + if ($item !== []) { + $items[] = $item; + } + } + foreach ((array) ($raw['value'] ?? []) as $item) { + if (is_array($item) && ! array_is_list($item)) { + $items[] = array_map(static fn (mixed $v): string => is_scalar($v) ? trim((string) $v) : '', $item); + } + } + + return $items; + } + + private function money(string $value): ?string + { + $normalized = str_replace([',', ',', '¥', '¥', '元', ' '], '', trim($value)); + if (! preg_match('/^-?\d+(\.\d+)?$/', $normalized)) { + return null; + } + + return number_format(round((float) $normalized, 2), 2, '.', ''); + } + + private function time(mixed $value): string + { + return substr(trim((string) $value), 0, 19); + } + + /** @return iterable */ + private function boundedGet(Builder $query): iterable + { + $connection = $query->getConnection(); + if ($connection->getDriverName() !== 'mysql') { + return $query->get(); + } + + return $connection->select( + 'SET STATEMENT max_statement_time=' . self::QUERY_TIMEOUT_SECONDS . ' FOR ' . $query->toSql(), + $query->getBindings(), + ); + } + + private function dataset(): BusinessMirrorDataset + { + return $this->datasets->get(self::DATASET); + } +} diff --git a/modules/EcommerceFinance/Services/PlantingLossCalculator.php b/modules/EcommerceFinance/Services/PlantingLossCalculator.php new file mode 100644 index 00000000..e05ea5bf --- /dev/null +++ b/modules/EcommerceFinance/Services/PlantingLossCalculator.php @@ -0,0 +1,292 @@ +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; + } + + /** 成本价(元,字符串)× 数量 × 1.2 → 分。 */ + public static function deductionCents(string $costPrice, int $qty): int + { + return (int) round((float) $costPrice * 100 * $qty * (100 + self::MARKUP_PERCENT) / 100); + } + + /** + * 当月全部种菜报销及其发货匹配、扣款判定(含未匹配,页面按结果筛选)。 + * + * @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; + foreach ($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 × 1.2 = %s', $line['barcode'], $line['qty'], $cost, number_format($lineCents / 100, 2, '.', '')); + } + + $keyword = $shipment === null ? null : self::exemptionKeyword((string) $row['task_remark'], (string) $row['seller_remark']); + [$result, $basis] = match (true) { + $shipment === null => [self::RESULT_UNMATCHED, self::shippingLabel($month) . ' 发货数据中无此交易单号'], + $keyword !== null => [self::RESULT_EXEMPT, '备注含“' . $keyword . '”'], + $deductLines === [] => [self::RESULT_FREE_GOODS, '只发了虚拟条码/透明挂钩'], + $missingCost !== [] => [self::RESULT_DEDUCT, '成本价缺失:' . implode('、', array_unique($missingCost)) . ',扣款金额未含该商品'], + default => [self::RESULT_DEDUCT, '已匹配发货,按成本价×1.2扣款'], + }; + + $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 && $missingCost !== []; + $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; + } +} diff --git a/modules/EcommerceFinance/View/PlantingLossPageData.php b/modules/EcommerceFinance/View/PlantingLossPageData.php new file mode 100644 index 00000000..c6768b8c --- /dev/null +++ b/modules/EcommerceFinance/View/PlantingLossPageData.php @@ -0,0 +1,134 @@ + '工单编号', + 'online_order_no' => '线上订单号', + 'shop_name' => '店铺名称', + 'task_remark' => '任务备注', + 'seller_remark' => '卖家备注', + 'express_no' => '快递单号', + ]; + + public const COLUMNS = [ + 'loss_result' => '损耗判定', 'deduction_amount' => '扣款金额', 'loss_basis' => '判定说明', 'deduction_detail' => '扣款计算(条码×数量×成本价×1.2)', + 'completed_at' => '报销完成时间', 'work_order_no' => '工单编号', 'platform' => '平台', 'shop_name' => '店铺名称', + 'online_order_no' => '线上订单号', 'task_remark' => '任务备注', 'seller_remark' => '卖家备注', + 'actual_paid_amount' => '实付金额', 'task_payment' => '任务付款金额', 'reimbursement_amount' => '最终报销金额', + 'shipped_at' => '发货时间', 'express_no' => '快递单号', 'warehouse_name' => '发货仓库', + 'shipped_barcode' => '发货条码', 'shipped_product_name' => '发货商品名称', 'shipped_qty' => '发货数量', + 'shipped_amount' => '发货金额', 'shipped_freight' => '发货运费', + 'initiator' => '发起人', 'payee_name' => '信誉账号名称', 'product_code' => '报销商品编码', 'product_name' => '报销商品名称', + 'internal_order_no' => '内部单号', 'erp_order_status' => 'ERP订单状态', + ]; + + public const MONEY_COLUMNS = ['deduction_amount', 'actual_paid_amount', 'task_payment', 'reimbursement_amount', 'shipped_amount', 'shipped_freight']; + + private const SUM_KEYS = ['deduction_amount', 'task_payment', 'reimbursement_amount', 'shipped_amount', 'shipped_freight']; + + public function __construct( + private readonly Calculator $calculator, + private readonly Repository $repository, + ) {} + + /** @return array */ + public function filters(): array + { + $filters = $this->baseFilters(); + $result = request()->query('result', Calculator::RESULT_DEDUCT); + abort_unless(is_string($result) && ($result === 'all' || in_array($result, Calculator::RESULTS, true)), 422, '损耗判定无效。'); + $filters['result'] = $result; + + return $filters; + } + + /** + * @param array $filters + * @param list>|null $rows + * @return \Generator> + */ + public function rows(array $filters, ?array $rows = null): \Generator + { + foreach ($rows ?? $this->calculator->rows($filters['month']) as $row) { + if ($filters['result'] !== 'all' && $row['loss_result'] !== $filters['result']) { + continue; + } + if ($this->matchesText($row, $filters)) { + yield $row; + } + } + } + + public function page(): array + { + $filters = $this->filters(); + $page = max(1, (int) request('page', 1)); + $sync = $this->repository->syncStatus(); + $shippingSync = $this->calculator->shippingStatus(); + $empty = ['count' => 0, 'cost_missing' => 0] + array_fill_keys(self::SUM_KEYS, 0); + $summary = ['total' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty); + $items = []; + $error = $this->syncError($sync); + + if ($error === null) { + try { + $all = $this->calculator->rows($filters['month']); + foreach ($all as $row) { + $summary['total']++; + $this->add($summary[$row['loss_result']], $row); + } + foreach ($this->rows($filters, $all) as $row) { + if ($summary['filtered']['count'] >= ($page - 1) * self::PER_PAGE && count($items) < self::PER_PAGE) { + $items[] = $row; + } + $this->add($summary['filtered'], $row); + } + } catch (\Throwable $exception) { + report($exception); + $error = '种菜损耗暂时无法计算(工单镜像或发货数据读取失败),请稍后重试。'; + $summary = ['total' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty); + $items = []; + } + } + + return [ + 'view' => 'business.pages.billing-transactions.planting-loss', + 'plantingFilters' => $filters, + 'plantingWindow' => Repository::window($filters['month']), + 'plantingShippingWindow' => Calculator::shippingWindow($filters['month']), + 'plantingMonths' => Repository::months(), + 'plantingSummary' => $summary, + 'plantingSync' => $sync, + 'plantingShippingSync' => $shippingSync, + 'plantingError' => $error, + 'plantingRows' => new LengthAwarePaginator($items, $summary['filtered']['count'], self::PER_PAGE, $page, [ + 'path' => request()->url(), + 'query' => request()->except('page'), + ]), + ]; + } + + /** @param array $bucket @param array $row */ + private function add(array &$bucket, array $row): void + { + $bucket['count']++; + $bucket['cost_missing'] += ! empty($row['cost_missing']) ? 1 : 0; + foreach (self::SUM_KEYS as $key) { + $bucket[$key] += self::cents($row[$key] ?? null); + } + } +} diff --git a/modules/EcommerceFinance/View/PlantingPageFilters.php b/modules/EcommerceFinance/View/PlantingPageFilters.php new file mode 100644 index 00000000..17ef9882 --- /dev/null +++ b/modules/EcommerceFinance/View/PlantingPageFilters.php @@ -0,0 +1,73 @@ + */ + protected function baseFilters(): array + { + $month = request()->query('month', ''); + abort_unless(is_string($month), 422, '报销月份无效。'); + $month = $month === '' ? Repository::defaultMonth() : $month; + abort_unless(in_array($month, Repository::months(), true), 422, '报销月份无效。'); + + $filters = ['month' => $month]; + foreach (array_keys(static::TEXT_FILTERS) as $key) { + $value = request()->query($key, ''); + abort_unless(is_string($value) && mb_strlen($value) <= 200, 422, '筛选条件格式无效。'); + $filters[$key] = trim($value); + } + + return $filters; + } + + /** @param array $row @param array $filters */ + protected function matchesText(array $row, array $filters): bool + { + foreach (array_keys(static::TEXT_FILTERS) as $key) { + if ($filters[$key] !== '' && mb_stripos((string) ($row[$key] ?? ''), $filters[$key]) === false) { + return false; + } + } + + return true; + } + + /** @return array 可导出列(按请求顺序) */ + public function exportColumns(): array + { + $requested = request()->query('columns'); + if ($requested === null || $requested === '') { + return static::COLUMNS; + } + abort_unless(is_string($requested) && strlen($requested) <= 1500, 422, '导出列无效。'); + $keys = explode(',', $requested); + abort_unless(count($keys) === count(array_unique($keys)) && array_diff($keys, array_keys(static::COLUMNS)) === [], 422, '导出列无效。'); + + return array_replace(array_flip($keys), array_intersect_key(static::COLUMNS, array_flip($keys))); + } + + public static function cents(mixed $amount): int + { + if ($amount === null || ! preg_match('/^(-?)(\d+)\.(\d{2})$/', (string) $amount, $m)) { + return 0; + } + $value = ((int) $m[2]) * 100 + (int) $m[3]; + + return $m[1] === '-' ? -$value : $value; + } + + /** @param array{code:string} $sync */ + protected function syncError(array $sync): ?string + { + return match ($sync['code']) { + 'ready', 'failed' => null, + 'initial_failed' => '聚工单工单镜像首次同步失败,暂不能展示种菜报销,请联系管理员重新同步。', + default => '聚工单工单镜像尚未完成首次同步,完成后自动展示;当前为空不代表没有种菜报销。', + }; + } +} diff --git a/modules/EcommerceFinance/View/PlantingReimbursementPageData.php b/modules/EcommerceFinance/View/PlantingReimbursementPageData.php new file mode 100644 index 00000000..805a4b9a --- /dev/null +++ b/modules/EcommerceFinance/View/PlantingReimbursementPageData.php @@ -0,0 +1,105 @@ + '工单编号', + 'online_order_no' => '线上订单号', + 'shop_name' => '店铺名称', + 'task_remark' => '任务备注', + 'seller_remark' => '卖家备注', + 'payee_name' => '信誉账号名称', + ]; + + public const COLUMNS = [ + 'completed_at' => '完成时间', 'started_at' => '发起时间', 'work_order_no' => '工单编号', 'status' => '工单状态', + 'platform' => '平台', 'shop_name' => '店铺名称', 'online_order_no' => '线上订单号', 'internal_order_no' => '内部单号', + 'task_remark' => '任务备注', 'seller_remark' => '卖家备注', + 'actual_paid_amount' => '实付金额', 'task_payment' => '任务付款金额', 'task_freight' => '任务快递费', + 'task_commission' => '任务佣金', 'jingxi_price_diff' => '京喜差价', 'task_deduction' => '任务扣款费用', + 'task_empty_package_fee' => '任务空包费', 'reimbursement_amount' => '最终报销金额', + 'payee_name' => '信誉账号名称', 'payee_account' => '信誉账号', 'payee_bank' => '信誉账号开户行', 'payee_mobile' => '收款人手机号', + 'initiator' => '发起人', 'executor' => '执行人', 'completer' => '完成人', + 'product_code' => '商品编码', 'product_name' => '商品名称', 'product_quantity' => '商品数量', 'platform_product_id' => '平台商品ID', + 'erp_order_status' => 'ERP订单状态', 'platform_order_status' => '平台订单状态', 'paid_at' => '付款时间', + ]; + + public const MONEY_COLUMNS = Repository::MONEY_FIELDS; + + public function __construct(private readonly Repository $repository) {} + + /** @return array */ + public function filters(): array + { + return $this->baseFilters(); + } + + /** + * @param array $filters + * @param list>|null $rows + * @return \Generator> + */ + public function rows(array $filters, ?array $rows = null): \Generator + { + foreach ($rows ?? $this->repository->rows($filters['month']) as $row) { + if ($this->matchesText($row, $filters)) { + yield $row; + } + } + } + + public function page(): array + { + $filters = $this->filters(); + $page = max(1, (int) request('page', 1)); + $sync = $this->repository->syncStatus(); + $summary = ['count' => 0, 'task_payment' => 0, 'reimbursement_amount' => 0, 'task_commission' => 0, 'task_empty_package_fee' => 0]; + $items = []; + $error = $this->syncError($sync); + + if ($error === null) { + try { + foreach ($this->rows($filters) as $row) { + if ($summary['count'] >= ($page - 1) * self::PER_PAGE && count($items) < self::PER_PAGE) { + $items[] = $row; + } + $summary['count']++; + foreach (['task_payment', 'reimbursement_amount', 'task_commission', 'task_empty_package_fee'] as $key) { + $summary[$key] += self::cents($row[$key]); + } + } + } catch (\Throwable $exception) { + report($exception); + $error = '种菜报销数据暂时无法读取,请稍后重试。'; + $summary = array_fill_keys(array_keys($summary), 0); + $items = []; + } + } + + return [ + 'view' => 'business.pages.billing-transactions.planting-reimbursements', + 'plantingFilters' => $filters, + 'plantingWindow' => Repository::window($filters['month']), + 'plantingMonths' => Repository::months(), + 'plantingSummary' => $summary, + 'plantingSync' => $sync, + 'plantingError' => $error, + 'plantingRows' => new LengthAwarePaginator($items, $summary['count'], self::PER_PAGE, $page, [ + 'path' => request()->url(), + 'query' => request()->except('page'), + ]), + ]; + } +} diff --git a/resources/views/business/pages/billing-transactions/partials/planting-work-order-assets.blade.php b/resources/views/business/pages/billing-transactions/partials/planting-work-order-assets.blade.php new file mode 100644 index 00000000..e4f74029 --- /dev/null +++ b/resources/views/business/pages/billing-transactions/partials/planting-work-order-assets.blade.php @@ -0,0 +1,52 @@ + +@push('body_end') + +@endpush diff --git a/resources/views/business/pages/billing-transactions/planting-loss.blade.php b/resources/views/business/pages/billing-transactions/planting-loss.blade.php new file mode 100644 index 00000000..d4efda69 --- /dev/null +++ b/resources/views/business/pages/billing-transactions/planting-loss.blade.php @@ -0,0 +1,69 @@ +@php + use Modules\EcommerceFinance\View\PlantingLossPageData as PageData; + use Modules\EcommerceFinance\Services\PlantingLossCalculator as Calculator; + $columns = PageData::COLUMNS; + $syncLabels = ['ready' => '工单镜像已同步', 'failed' => '工单镜像最近同步失败,展示上次成功数据', 'initializing' => '工单镜像首次同步中', 'initial_failed' => '工单镜像首次同步失败', 'unavailable' => '工单镜像未建立']; + $shippingLabels = ['succeeded' => '发货数据已同步', 'partial' => '发货数据部分待核对', 'running' => '发货数据同步中', 'failed' => '发货数据最近同步失败', 'unavailable' => '发货数据不可用']; + $resultClasses = [Calculator::RESULT_DEDUCT => 'is-deduct', Calculator::RESULT_EXEMPT => 'is-exempt', Calculator::RESULT_FREE_GOODS => 'is-exempt']; + $shippingLabel = Calculator::shippingLabel($plantingFilters['month']); + $baseQuery = array_filter(\Illuminate\Support\Arr::except($plantingFilters, ['result']), static fn ($v) => $v !== ''); + $resultUrl = static fn (string $result) => request()->url() . '?' . http_build_query($baseQuery + ['result' => $result]); + $exportQuery = array_filter($plantingFilters, static fn ($v) => $v !== ''); + $money = static fn (int $cents) => number_format($cents / 100, 2); +@endphp +
+
+

{{ $plantingFilters['month'] }} 月种菜报销明细(完成时间 {{ substr($plantingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingWindow['to'])->subDay()->format('Y-m-d') }})的线上订单号,匹配 {{ $shippingLabel }} 发货数据(发货时间 {{ substr($plantingShippingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingShippingWindow['to'])->subDay()->format('Y-m-d') }})中的交易单号。匹配到的单中,任务备注或卖家备注写了“拍A发B / 拍A发A”的、以及只发了信誉订单虚拟条码/透明挂钩的不扣款,其余按发货商品成本价×1.2 扣款。

+
+ {{ $syncLabels[$plantingSync['code']] ?? $plantingSync['code'] }}@if($plantingSync['last_succeeded_at']) · {{ \Illuminate\Support\Str::of($plantingSync['last_succeeded_at'])->substr(0, 16) }}@endif + {{ $shippingLabels[$plantingShippingSync['code'] ?? 'unavailable'] ?? $plantingShippingSync['code'] }}@if($plantingShippingSync['last_succeeded_at'] ?? null) · {{ \Illuminate\Support\Str::of($plantingShippingSync['last_succeeded_at'])->substr(0, 16) }}@endif +
+
+
+ + + @foreach(PageData::TEXT_FILTERS as $key => $label) + + @endforeach + + 重置 + 导出当前筛选 + 导出拍A发B(发运营) +
列设置

勾选显示,拖动调整顺序

@foreach($columns as $key => $label)@endforeach
+
+ @php $deduct = $plantingSummary[Calculator::RESULT_DEDUCT]; $exempt = $plantingSummary[Calculator::RESULT_EXEMPT]; $free = $plantingSummary[Calculator::RESULT_FREE_GOODS]; $unmatched = $plantingSummary[Calculator::RESULT_UNMATCHED]; @endphp + +
计算口径

① 取该月种菜报销明细:工单类型“种菜报销”、状态“已完成”,完成时间 M 月 2 日 00:00 至次月 1 日 23:59:59(与「种菜报销」页同一口径)。② 用线上订单号(交易单号)精确匹配「快递应付 → 发货数据」中发货时间在上月 1 日至本月末的交易单号,匹配到的保留,同一单多个包裹/商品合并为一行。③ 匹配到的单中,任务备注或卖家备注写了“拍A发B”或“拍A发A”(不区分全半角、大小写和空格)的不做扣款,单独列出发给运营。④ 实际只发了“信誉订单虚拟条码”“透明挂钩”这类商品的不扣款;同一单里这类商品也不计入扣款。⑤ 其余匹配到的为扣款项:扣款金额 = Σ 发货条形码(商品编码)在「普通商品资料」中的成本价 × 发货数量 × 1.2(成本价 + 成本价×20%);普通商品资料里没有成本价(空或 0)的商品标为“成本价缺失”,不计入扣款金额,需补齐成本价后自动重算。未匹配到发货的单不计入种菜损耗,仅供核对。

+ @if(($plantingShippingSync['code'] ?? '') !== 'succeeded' && ! $plantingError)
发货数据当前状态为“{{ $shippingLabels[$plantingShippingSync['code'] ?? 'unavailable'] ?? $plantingShippingSync['code'] }}”{{ ($plantingShippingSync['warning'] ?? '') !== '' ? ':' . $plantingShippingSync['warning'] : '' }},匹配结果可能不完整。
@endif + @if($plantingError)@endif +
+
+ @foreach($columns as $key => $label)@endforeach + + @forelse($plantingRows as $row) + @foreach($columns as $key => $label) + @if(in_array($key, PageData::MONEY_COLUMNS, true)) + + @elseif($key === 'loss_result') + + @else + + @endif + @endforeach + @empty + + @endforelse +
{{ $label }}
{{ $row[$key] === null ? '—' : number_format((float) $row[$key], 2) }}{{ $row['loss_result'] }}{{ ($row[$key] ?? '') !== '' ? $row[$key] : '—' }}
{{ $plantingError ?: '当前筛选条件下没有种菜损耗明细。' }}
+ +
+
+@include('business.pages.billing-transactions.partials.planting-work-order-assets') diff --git a/resources/views/business/pages/billing-transactions/planting-reimbursements.blade.php b/resources/views/business/pages/billing-transactions/planting-reimbursements.blade.php new file mode 100644 index 00000000..e23c6c10 --- /dev/null +++ b/resources/views/business/pages/billing-transactions/planting-reimbursements.blade.php @@ -0,0 +1,50 @@ +@php + use Modules\EcommerceFinance\View\PlantingReimbursementPageData as PageData; + $columns = PageData::COLUMNS; + $syncLabels = ['ready' => '工单镜像已同步', 'failed' => '最近一次同步失败,展示上次成功数据', 'initializing' => '首次同步中', 'initial_failed' => '首次同步失败', 'unavailable' => '镜像未建立']; + $exportQuery = array_filter($plantingFilters, static fn ($v) => $v !== ''); +@endphp +
+
+

聚水潭聚工单「打款工单 → 种菜报销」中工单状态为已完成的明细,按完成时间归属报销月份:{{ $plantingFilters['month'] }} 月 = 完成时间 {{ substr($plantingWindow['from'], 0, 16) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingWindow['to'])->subSecond()->format('Y-m-d H:i:s') }}(当天报销前一天的种菜)。

+
{{ $syncLabels[$plantingSync['code']] ?? $plantingSync['code'] }}@if($plantingSync['last_succeeded_at']) · {{ \Illuminate\Support\Str::of($plantingSync['last_succeeded_at'])->substr(0, 16) }}@endif
+
+
+ + @foreach(PageData::TEXT_FILTERS as $key => $label) + + @endforeach + + 重置 + 导出当前筛选 +
列设置

勾选显示,拖动调整顺序

@foreach($columns as $key => $label)@endforeach
+
+
+
种菜报销工单{{ number_format($plantingSummary['count']) }}
+
任务付款金额(元){{ number_format($plantingSummary['task_payment'] / 100, 2) }}
+
任务佣金(元){{ number_format($plantingSummary['task_commission'] / 100, 2) }}
+
任务空包费(元){{ number_format($plantingSummary['task_empty_package_fee'] / 100, 2) }}
+
最终报销金额(元){{ number_format($plantingSummary['reimbursement_amount'] / 100, 2) }}
+
+
接入口径

工单类型为“种菜报销”、工单状态为“已完成”,按完成时间筛选,自 2026-09-02 00:00 起接入。种菜报销都是当天报销前一天的种菜明细,所以 M 月的种菜报销明细 = 完成时间 M 月 2 日 00:00 至次月 1 日 23:59:59。同一工单号只取最新一版,完成后被作废的不计入。数据只读自聚工单镜像,约每 10 分钟随镜像同步更新,不在此修改。

+ @if($plantingError)@endif +
+
+ @foreach($columns as $key => $label)@endforeach + + @forelse($plantingRows as $row) + @foreach($columns as $key => $label) + @if(in_array($key, PageData::MONEY_COLUMNS, true)) + + @else + + @endif + @endforeach + @empty + + @endforelse +
{{ $label }}
{{ $row[$key] === null ? '—' : number_format((float) $row[$key], 2) }}{{ ($row[$key] ?? '') !== '' ? $row[$key] : '—' }}
{{ $plantingError ?: '当前筛选条件下暂无种菜报销,请调整筛选条件。' }}
+ +
+
+@include('business.pages.billing-transactions.partials.planting-work-order-assets') diff --git a/routes/admin.php b/routes/admin.php index aaa2284c..747def34 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -270,6 +270,14 @@ Route::get('workspace/planting-data/jst-order-labels/export', 'Business\Pages@ex ->middleware('business.page:planting-data,jst-order-labels') ->name('business.planting-data.jst-order-labels.export'); +Route::get('workspace/planting-data/planting-reimbursements/export', [\App\Http\Controllers\Business\PlantingWorkOrders::class, 'exportReimbursements']) + ->middleware('business.page:planting-data,planting-reimbursements') + ->name('business.planting-data.planting-reimbursements.export'); + +Route::get('workspace/planting-data/planting-loss/export', [\App\Http\Controllers\Business\PlantingWorkOrders::class, 'exportLoss']) + ->middleware('business.page:planting-data,planting-loss') + ->name('business.planting-data.planting-loss.export'); + Route::prefix('workspace/product-inventory/inventory-report/warehouse-mappings') ->middleware('business.page:product-inventory,inventory-report') ->name('business.product-inventory.inventory-report.warehouse-mappings.') diff --git a/tests/Feature/Business/PlantingWorkOrderPagesTest.php b/tests/Feature/Business/PlantingWorkOrderPagesTest.php new file mode 100644 index 00000000..2540851d --- /dev/null +++ b/tests/Feature/Business/PlantingWorkOrderPagesTest.php @@ -0,0 +1,113 @@ + '2026-09-02 11:47:53', 'work_order_no' => 'GD565967381', 'status' => '已完成', + 'shop_name' => '天猫-卓禾家具旗舰店', 'online_order_no' => '3316392626048007769', 'task_remark' => 'CCDD', + 'seller_remark' => 'AABB', 'actual_paid_amount' => '37.79', 'task_payment' => '37.79', 'task_freight' => '0.00', + 'task_commission' => '2.00', 'jingxi_price_diff' => '0.00', 'task_deduction' => '0.00', + 'task_empty_package_fee' => '0.00', 'reimbursement_amount' => '39.79', 'payee_name' => '黄文梳', + 'loss_result' => Calculator::RESULT_DEDUCT, 'loss_basis' => '已匹配发货,备注无拍A发B/拍A发A', + 'shipped_amount' => '40.60', 'shipped_freight' => '3.20', 'express_no' => 'YT001', + ], $patch); + } + + private function mockData(array $rows): void + { + $repository = \Mockery::mock(PlantingReimbursementRepository::class); + $repository->shouldReceive('syncStatus')->andReturn(['code' => 'ready', 'last_succeeded_at' => '2026-10-19 13:00:00']); + $repository->shouldReceive('rows')->andReturn($rows); + $this->app->instance(PlantingReimbursementRepository::class, $repository); + + $calculator = \Mockery::mock(Calculator::class); + $calculator->shouldReceive('rows')->andReturn($rows); + $calculator->shouldReceive('shippingStatus')->andReturn(['code' => 'succeeded', 'last_succeeded_at' => '2026-10-19 12:00:00']); + $this->app->instance(Calculator::class, $calculator); + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + } + + private function url(string $page, array $query = []): string + { + return route('business.pages.show', ['company_id' => $this->company->id, 'section' => 'planting-data', 'page' => $page]) + . ($query ? '?' . http_build_query($query) : ''); + } + + public function test_reimbursement_page_lists_month_rows_and_totals(): void + { + $this->mockData([$this->row(), $this->row(['work_order_no' => 'GD-2', 'shop_name' => '拼多多-归来舍下', 'reimbursement_amount' => '0.56', 'task_payment' => '0.06'])]); + + $this->loginAs()->get($this->url('planting-reimbursements', ['month' => '2026-09'])) + ->assertOk() + ->assertSee('种菜报销') + ->assertSee('GD565967381') + ->assertSee('GD-2') + ->assertSee('40.35') + ->assertSee('2026-09-02 00:00'); + + $this->loginAs()->get($this->url('planting-reimbursements', ['month' => '2026-09', 'shop_name' => '拼多多'])) + ->assertOk()->assertSee('GD-2')->assertDontSee('GD565967381'); + + $this->withExceptionHandling()->loginAs()->get($this->url('planting-reimbursements', ['month' => '2026-08']))->assertStatus(422); + } + + public function test_loss_page_defaults_to_deduct_rows_and_splits_exempt(): void + { + $this->mockData([ + $this->row(), + $this->row(['work_order_no' => 'GD-EXEMPT', 'task_remark' => '拍A发B+GG003', 'loss_result' => Calculator::RESULT_EXEMPT, 'loss_basis' => '备注含“拍A发B”']), + $this->row(['work_order_no' => 'GD-NONE', 'loss_result' => Calculator::RESULT_UNMATCHED, 'shipped_amount' => null]), + ]); + + $this->loginAs()->get($this->url('planting-loss', ['month' => '2026-09'])) + ->assertOk() + ->assertSee('种菜损耗') + ->assertSee('GD565967381') + ->assertDontSee('GD-EXEMPT') + ->assertDontSee('GD-NONE') + ->assertSee('2026-08-01'); + + $this->loginAs()->get($this->url('planting-loss', ['month' => '2026-09', 'result' => Calculator::RESULT_EXEMPT])) + ->assertOk()->assertSee('GD-EXEMPT')->assertDontSee('GD565967381'); + + $this->withExceptionHandling()->loginAs()->get($this->url('planting-loss', ['month' => '2026-09', 'result' => 'x']))->assertStatus(422); + } + + public function test_loss_export_for_operations_contains_only_exempt_rows(): void + { + $this->mockData([ + $this->row(), + $this->row(['work_order_no' => 'GD-EXEMPT', 'task_remark' => '拍A发B', 'loss_result' => Calculator::RESULT_EXEMPT]), + ]); + + $response = $this->loginAs()->get(route('business.planting-data.planting-loss.export', [ + 'company_id' => $this->company->id, 'month' => '2026-09', 'result' => Calculator::RESULT_EXEMPT, + 'columns' => 'work_order_no,online_order_no,task_remark,loss_result', + ])); + + $response->assertOk(); + $csv = $response->streamedContent(); + $this->assertStringContainsString('工单编号,线上订单号,任务备注,损耗判定', $csv); + $this->assertStringContainsString("GD-EXEMPT,'3316392626048007769,拍A发B", $csv); + $this->assertStringNotContainsString('GD565967381', $csv); + + $reimbursements = $this->loginAs()->get(route('business.planting-data.planting-reimbursements.export', [ + 'company_id' => $this->company->id, 'month' => '2026-09', + ])); + $reimbursements->assertOk(); + $this->assertStringContainsString('最终报销金额', $reimbursements->streamedContent()); + } +} diff --git a/tests/Unit/EcommerceFinance/PlantingLossCalculatorTest.php b/tests/Unit/EcommerceFinance/PlantingLossCalculatorTest.php new file mode 100644 index 00000000..2790bced --- /dev/null +++ b/tests/Unit/EcommerceFinance/PlantingLossCalculatorTest.php @@ -0,0 +1,217 @@ + ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], + 'database.connections.planting_shipping' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], + 'jushuitan.connection' => 'planting_shipping', + 'upstream_mirrors.connection' => 'business_mirror', + 'upstream_mirrors.datasets' => [ + Repository::DATASET => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_workOrderInfo', + 'target' => 'fixture_work_orders', 'keys' => ['Id'], 'watermark' => 'updateTime', + 'indexes' => [['woTypeId', 'woNo', 'Id']], + ], + Calculator::SKU_DATASET => [ + 'source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_sku', + 'target' => 'fixture_sku', 'keys' => ['autoid'], 'watermark' => 'modified', + ], + ], + ]); + Cache::setDefaultDriver('array'); + Cache::flush(); + DB::purge('business_mirror'); + DB::purge('planting_shipping'); + + $schema = Schema::connection('business_mirror'); + $schema->create('business_mirror_table_states', function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->string('status')->nullable(); + $table->dateTime('initial_completed_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + }); + $schema->create('fixture_work_orders', function (Blueprint $table): void { + $table->integer('Id'); + foreach (['woNo', 'woTypeId', 'status', 'createby', 'executors', 'completer', 'completeTime', 'created', 'updateTime', 'businessFieldList'] as $column) { + $table->text($column)->nullable(); + } + }); + $schema->create('fixture_sku', function (Blueprint $table): void { + $table->integer('autoid'); + $table->string('sku_id'); + $table->string('sku_type'); + $table->decimal('cost_price', 18, 4)->nullable(); + }); + DB::connection('business_mirror')->table('fixture_sku')->insert([ + ['autoid' => 1, 'sku_id' => 'DNZ-01', 'sku_type' => 'normal', 'cost_price' => 90], + ['autoid' => 2, 'sku_id' => 'DNZ-01', 'sku_type' => 'normal', 'cost_price' => 100.5], + ['autoid' => 3, 'sku_id' => 'DNZ-02', 'sku_type' => 'combine', 'cost_price' => 50], + ['autoid' => 4, 'sku_id' => 'ZERO', 'sku_type' => 'normal', 'cost_price' => 0], + ]); + DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ + 'dataset_key' => Repository::DATASET, 'status' => 'succeeded', + 'initial_completed_at' => '2026-09-08 16:00:00', 'last_succeeded_at' => '2026-10-19 13:00:00', + ]); + + $shipping = Schema::connection('planting_shipping'); + $shipping->create('jst_oms_shipping', function (Blueprint $table): void { + $table->increments('id'); + foreach (['express_no', 'trade_no', 'barcode', 'product_name', 'warehouse_name'] as $column) { + $table->string($column)->nullable(); + } + $table->integer('qty')->nullable(); + $table->decimal('amount', 18, 2)->nullable(); + $table->decimal('freight', 18, 2)->nullable(); + $table->dateTime('ship_time')->nullable(); + }); + $shipping->create('jst_sync_state', function (Blueprint $table): void { + $table->string('dataset_key'); + $table->string('status')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + }); + DB::connection('planting_shipping')->table('jst_sync_state')->insert(['dataset_key' => 'jst.sctrade', 'status' => 'succeeded', 'last_succeeded_at' => '2026-10-19 12:00:00']); + } + + private function workOrder(int $id, string $woNo, string $orderNo, string $completeTime, array $patch = [], string $taskRemark = '', string $sellerRemark = ''): void + { + $fields = [ + ['fieldId' => 'orderField', 'type' => 'TEXT', 'value' => $orderNo], + ['fieldId' => 'shopField', 'type' => 'SELECT', 'value' => '18501511', 'valueLabel' => '天猫-亿家达旗舰店'], + ['fieldId' => 'platform', 'type' => 'SELECT', 'value' => '淘宝天猫'], + ['fieldId' => 'actualPaidAmountField', 'type' => 'MONEY', 'value' => '37.79'], + ['fieldId' => 'NUMBERField1413856620250924131615', 'type' => 'NUMBER', 'value' => '37.79'], + ['fieldId' => 'NUMBERField1413856620251230154457', 'type' => 'NUMBER', 'value' => '2'], + ['fieldId' => 'NUMBERField1413856620251117101250', 'type' => 'NUMBER', 'value' => '39.79'], + ['fieldId' => 'TEXTField1413856620251117180931', 'type' => 'TEXT', 'value' => '黄文梳'], + ['fieldId' => 'TEXTField1413856620251117112729', 'type' => 'TEXT', 'value' => '招商银行'], + ['fieldId' => 'TEXTField1413856620251113150546', 'type' => 'TEXT', 'value' => $taskRemark], + ['fieldId' => 'businessRemarkField', 'type' => 'TEXTAREA', 'value' => $sellerRemark], + ['fieldId' => 'erpOrderStatus', 'type' => 'SELECT', 'value' => 'Sent'], + ['fieldId' => 'itemInfo', 'type' => 'FORM', 'childFieldList' => [[ + ['fieldId' => 'skuId', 'type' => 'TEXT', 'value' => 'GG003-SZ'], + ['fieldId' => 'name', 'type' => 'TEXT', 'value' => '透明挂钩3个'], + ['fieldId' => 'qty', 'type' => 'NUMBER', 'value' => '1'], + ]]], + ]; + DB::connection('business_mirror')->table('fixture_work_orders')->insert(array_replace([ + 'Id' => $id, 'woNo' => $woNo, 'woTypeId' => Repository::WORK_TYPE_ID, 'status' => 'CONFIRMED', + 'createby' => '怀德', 'executors' => '李斯', 'completer' => '李斯', 'completeTime' => $completeTime, + 'created' => '2026-09-01 09:00:00', 'updateTime' => $completeTime, + 'businessFieldList' => json_encode($fields, JSON_UNESCAPED_UNICODE), + ], $patch)); + } + + private function ship(string $tradeNo, string $shipTime, string $expressNo = 'YT001', float $amount = 40.6, string $barcode = 'DNZ-01', string $name = '儿童学习椅', int $qty = 1): void + { + DB::connection('planting_shipping')->table('jst_oms_shipping')->insert([ + 'trade_no' => $tradeNo, 'express_no' => $expressNo, 'barcode' => $barcode, 'product_name' => $name, + 'warehouse_name' => '常州仓', 'qty' => $qty, 'amount' => $amount, 'freight' => 3.2, 'ship_time' => $shipTime, + ]); + } + + public function test_windows_follow_completion_and_two_month_shipping_rules(): void + { + $this->assertSame(['from' => '2026-09-02 00:00:00', 'to' => '2026-10-02 00:00:00'], Repository::window('2026-09')); + $this->assertSame(['from' => '2026-12-02 00:00:00', 'to' => '2027-01-02 00:00:00'], Repository::window('2026-12')); + $this->assertSame(['from' => '2026-08-01 00:00:00', 'to' => '2026-10-01 00:00:00'], Calculator::shippingWindow('2026-09')); + $this->assertSame('2026-09', Repository::defaultMonth(CarbonImmutable::parse('2026-10-01 18:00:00'))); + $this->assertSame('2026-10', Repository::defaultMonth(CarbonImmutable::parse('2026-10-19 10:00:00'))); + $this->assertSame(['2026-10', '2026-09'], Repository::months(CarbonImmutable::parse('2026-10-19'))); + + $this->expectException(\InvalidArgumentException::class); + Repository::window('2026-08'); + } + + public function test_exemption_keyword_tolerates_width_case_and_spaces(): void + { + $this->assertSame('拍A发B', Calculator::exemptionKeyword('拍A发B+GG003-SZ')); + $this->assertSame('拍A发A', Calculator::exemptionKeyword('', '客户要求 拍a 发A')); + $this->assertSame('拍A发B', Calculator::exemptionKeyword('CCDD', '拍a发b,供货价一致')); + $this->assertNull(Calculator::exemptionKeyword('AABB', '拍B发A')); + $this->assertNull(Calculator::exemptionKeyword('转挂钩发货', '')); + $this->assertTrue(Calculator::isFreeGoods('XN001', '信誉订单虚拟条码')); + $this->assertTrue(Calculator::isFreeGoods('GG003-SZ', '透明挂钩3个')); + $this->assertFalse(Calculator::isFreeGoods('DNZ-01', '儿童学习椅')); + $this->assertSame(12060, Calculator::deductionCents('100.5', 1)); + $this->assertSame(24120, Calculator::deductionCents('100.5', 2)); + } + + public function test_loss_rows_match_shipments_and_split_exempt_from_deduct(): void + { + $this->workOrder(1, 'GD-DEDUCT', '3316392626048007769', '2026-09-02 00:00:00', [], 'CCDD', 'AABB'); + $this->workOrder(2, 'GD-EXEMPT', '3604408018648198', '2026-09-15 11:47:53', [], '拍A发B+GG003-SZ'); + $this->workOrder(3, 'GD-SELLER', '260901-000000000000001', '2026-10-01 23:59:59', [], '', '拍A发A'); + $this->workOrder(4, 'GD-UNMATCHED', '9999', '2026-09-20 10:00:00'); + $this->workOrder(5, 'GD-OUTSIDE', '3316392626048007769', '2026-10-02 00:00:00'); + $this->workOrder(6, 'GD-AUG', '1111', '2026-09-01 23:59:59'); + $this->workOrder(7, 'GD-OTHER-TYPE', '2222', '2026-09-10 10:00:00', ['woTypeId' => 'OTHER']); + // 同一工单两版:取最新一版;最新一版作废则不计入。 + $this->workOrder(8, 'GD-DISABLED', '3333', '2026-09-10 10:00:00'); + $this->workOrder(9, 'GD-DISABLED', '3333', '2026-09-10 10:00:00', ['status' => 'DISABLED']); + + $this->workOrder(10, 'GD-VIRTUAL', '5555', '2026-09-20 10:00:00'); + $this->workOrder(11, 'GD-NOCOST', '6666', '2026-09-21 10:00:00'); + + // 一单两个包裹:学习椅 ×2 + 透明挂钩(挂钩不扣)。 + $this->ship('3316392626048007769', '2026-08-28 10:00:00', 'YT001', 40.6, 'DNZ-01', '儿童学习椅', 2); + $this->ship('3316392626048007769', '2026-08-28 10:00:00', 'YT002', 10, 'GG003-SZ', '透明挂钩3个'); + $this->ship('3604408018648198', '2026-09-30 23:59:59'); + $this->ship('260901-000000000000001', '2026-09-01 08:00:00'); + $this->ship('9999', '2026-07-31 23:59:59'); // 早于上月 1 日 + $this->ship('9999', '2026-10-01 00:00:00'); // 晚于本月末 + $this->ship('5555', '2026-09-10 08:00:00', 'YT555', 0, 'XN-001', '信誉订单虚拟条码'); + $this->ship('5555', '2026-09-10 08:00:00', 'YT555', 0, 'GG003-SZ', '透明挂钩3个'); + $this->ship('6666', '2026-09-10 08:00:00', 'YT666', 0, 'ZERO', '无成本商品'); + $this->ship('6666', '2026-09-10 08:00:00', 'YT666', 0, 'DNZ-02', '组合商品'); + + $rows = collect($this->app->make(Calculator::class)->rows('2026-09'))->keyBy('work_order_no'); + + $this->assertSame(['GD-DEDUCT', 'GD-EXEMPT', 'GD-NOCOST', 'GD-SELLER', 'GD-UNMATCHED', 'GD-VIRTUAL'], $rows->keys()->sort()->values()->all()); + $this->assertSame(Calculator::RESULT_DEDUCT, $rows['GD-DEDUCT']['loss_result']); + $this->assertSame("YT001\nYT002", $rows['GD-DEDUCT']['express_no']); + $this->assertSame('50.60', $rows['GD-DEDUCT']['shipped_amount']); + $this->assertSame('3', $rows['GD-DEDUCT']['shipped_qty']); + // 取最新一条普通商品成本价 100.5:100.5 × 2 × 1.2 = 241.20,挂钩不计。 + $this->assertSame('241.20', $rows['GD-DEDUCT']['deduction_amount']); + $this->assertSame('DNZ-01 × 2 × 100.5 × 1.2 = 241.20', $rows['GD-DEDUCT']['deduction_detail']); + $this->assertFalse($rows['GD-DEDUCT']['cost_missing']); + + $this->assertSame(Calculator::RESULT_FREE_GOODS, $rows['GD-VIRTUAL']['loss_result']); + $this->assertNull($rows['GD-VIRTUAL']['deduction_amount']); + + // 成本价为 0、或只有组合装资料的条码视为成本价缺失。 + $this->assertSame(Calculator::RESULT_DEDUCT, $rows['GD-NOCOST']['loss_result']); + $this->assertSame('0.00', $rows['GD-NOCOST']['deduction_amount']); + $this->assertTrue($rows['GD-NOCOST']['cost_missing']); + $this->assertStringContainsString('ZERO、DNZ-02', $rows['GD-NOCOST']['loss_basis']); + $this->assertNull($rows['GD-EXEMPT']['deduction_amount']); + $this->assertSame(Calculator::RESULT_EXEMPT, $rows['GD-EXEMPT']['loss_result']); + $this->assertSame('备注含“拍A发B”', $rows['GD-EXEMPT']['loss_basis']); + $this->assertSame(Calculator::RESULT_EXEMPT, $rows['GD-SELLER']['loss_result']); + $this->assertSame(Calculator::RESULT_UNMATCHED, $rows['GD-UNMATCHED']['loss_result']); + + $row = $rows['GD-DEDUCT']; + $this->assertSame('天猫-亿家达旗舰店', $row['shop_name']); + $this->assertSame('37.79', $row['task_payment']); + $this->assertSame('39.79', $row['reimbursement_amount']); + $this->assertSame('已发货', $row['erp_order_status']); + $this->assertSame('GG003-SZ', $row['product_code']); + $this->assertSame('2026-09-02 00:00:00', $row['completed_at']); + } +}