feat(种菜数据汇总): 接入聚工单种菜报销,新增种菜损耗扣款页面
- 种菜报销:复用已登记镜像 jiansun.dbo.jst_workOrderInfo,工单类型“种菜报销”、状态已完成, 按完成时间归属报销月份(M 月 = [M-02 00:00, M+1-02 00:00)),自 2026-09-02 起;同一工单只取最新一版 - 种菜损耗:当月种菜报销的线上订单号匹配上月+本月发货数据(jst_oms_shipping)交易单号; 任务备注/卖家备注含“拍A发B/拍A发A”的不扣款(单独导出发运营),只发信誉订单虚拟条码/透明挂钩的不扣款, 其余按发货条码在普通商品资料(ops_jst_sku)的成本价 × 数量 × 1.2 扣款,成本价缺失单独提示 - 两页均支持筛选、列设置、CSV 导出;按月缓存 10 分钟 - 测试:窗口口径、免扣字样、虚拟条码/挂钩、成本价取数与缺失、页面筛选和导出 涉及:金额计算展示(只读);不涉及数据库迁移、权限模型和上游写入。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
This commit is contained in:
parent
566a3ef4db
commit
ea479733ab
@ -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(),
|
||||
|
||||
75
app/Http/Controllers/Business/PlantingWorkOrders.php
Normal file
75
app/Http/Controllers/Business/PlantingWorkOrders.php
Normal file
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\Services\PlantingLossCalculator;
|
||||
use Modules\EcommerceFinance\View\PlantingLossPageData;
|
||||
use Modules\EcommerceFinance\View\PlantingReimbursementPageData;
|
||||
|
||||
/** 种菜数据汇总:种菜报销 / 种菜损耗 导出(与页面同一筛选口径)。 */
|
||||
class PlantingWorkOrders extends Controller
|
||||
{
|
||||
public function exportReimbursements(PlantingReimbursementPageData $data)
|
||||
{
|
||||
$filters = $data->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<string,string> $columns @param callable():iterable<array<string,mixed>> $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']);
|
||||
}
|
||||
}
|
||||
@ -540,6 +540,8 @@ $navigation = [
|
||||
'pages' => [
|
||||
['slug' => 'planting-details', 'title' => '种菜明细表'],
|
||||
['slug' => 'jst-order-labels', 'title' => '聚水潭订单标签明细'],
|
||||
['slug' => 'planting-reimbursements', 'title' => '种菜报销'],
|
||||
['slug' => 'planting-loss', 'title' => '种菜损耗'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@ -0,0 +1,367 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use InvalidArgumentException;
|
||||
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 聚水潭聚工单「打款工单 → 种菜报销」:只读工单镜像 jiansun.dbo.jst_workOrderInfo。
|
||||
*
|
||||
* 口径(2026-10-19 财务确认):工单类型“种菜报销”、工单状态“已完成”,按完成时间筛选,
|
||||
* 自 2026-09-02 00:00 起接入。种菜报销都是当天报销前一天的明细,所以 M 月种菜报销明细
|
||||
* = 完成时间 [M 月 2 日 00:00, M+1 月 2 日 00:00)。同一工单号只取最新一版。
|
||||
*/
|
||||
class PlantingReimbursementRepository
|
||||
{
|
||||
public const DATASET = 'jiansun.dbo.jst_workOrderInfo';
|
||||
|
||||
public const WORK_TYPE_ID = '6F03128403F2442CABCF283B59FAD88D';
|
||||
|
||||
public const STATUS_CONFIRMED = 'CONFIRMED';
|
||||
|
||||
public const START_AT = '2026-09-02 00:00:00';
|
||||
|
||||
public const FIRST_MONTH = '2026-09';
|
||||
|
||||
/** 聚工单只返回 fieldId;对应关系由历史种菜明细逐列对撞及打款工单列表核对得出。 */
|
||||
public const FIELDS = [
|
||||
'online_order_no' => '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<string> 可选报销月份(新到旧) */
|
||||
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<array<string,mixed>>
|
||||
*/
|
||||
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<array<string,mixed>> */
|
||||
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<string,object> $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<string,mixed> */
|
||||
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<string,array{value:mixed,label:mixed,raw:array<mixed>}> */
|
||||
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<mixed>|null $raw
|
||||
* @return list<array<string,string>>
|
||||
*/
|
||||
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<int,object> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
292
modules/EcommerceFinance/Services/PlantingLossCalculator.php
Normal file
292
modules/EcommerceFinance/Services/PlantingLossCalculator.php
Normal file
@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository;
|
||||
use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
|
||||
|
||||
/**
|
||||
* 种菜损耗(2026-10-19 财务确认的口径):
|
||||
* 1. 取 M 月种菜报销明细(完成时间 [M-02, M+1-02));
|
||||
* 2. 用线上订单号去匹配发货数据(jst_oms_shipping)中 M-1、M 两个月发货的交易单号,匹配到的保留;
|
||||
* 3. 匹配到的单里,任务备注或卖家备注写了“拍A发B”或“拍A发A”的不扣款,单独列出发给运营;
|
||||
* 4. 实际只发了「信誉订单虚拟条码」「透明挂钩」这类商品的不扣款;
|
||||
* 5. 其余匹配到的作为扣款项,扣款金额 = Σ 发货商品成本价 × 数量 × 1.2(成本价+成本价×20%),
|
||||
* 成本价按发货条形码(=商品编码)取普通商品资料。未匹配到发货的不计入种菜损耗。
|
||||
*/
|
||||
class PlantingLossCalculator
|
||||
{
|
||||
public const RESULT_DEDUCT = '扣款';
|
||||
|
||||
public const RESULT_EXEMPT = '不扣款(拍A发B/拍A发A)';
|
||||
|
||||
public const RESULT_FREE_GOODS = '不扣款(虚拟条码/透明挂钩)';
|
||||
|
||||
public const RESULT_UNMATCHED = '未匹配发货';
|
||||
|
||||
public const RESULTS = [self::RESULT_DEDUCT, self::RESULT_EXEMPT, self::RESULT_FREE_GOODS, self::RESULT_UNMATCHED];
|
||||
|
||||
/** 发货商品名称或条码含这些字样的不计扣款。 */
|
||||
public const FREE_GOODS_KEYWORDS = ['信誉订单虚拟条码', '虚拟条码', '透明挂钩'];
|
||||
|
||||
/** 扣款 = 成本价 × (1 + 20%)。 */
|
||||
public const MARKUP_PERCENT = 20;
|
||||
|
||||
public const SKU_DATASET = 'operations_replica.rpt.jst_sku';
|
||||
|
||||
private const CHUNK = 1000;
|
||||
|
||||
private const CACHE_SECONDS = 600;
|
||||
|
||||
public function __construct(
|
||||
private readonly PlantingReimbursementRepository $reimbursements,
|
||||
private readonly ShippingOrderRepository $shipping,
|
||||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||||
private readonly GenericBusinessMirrorRepository $mirror,
|
||||
) {}
|
||||
|
||||
/** 发货数据同步状态(与「发货数据」页面同一来源)。 */
|
||||
public function shippingStatus(): array
|
||||
{
|
||||
return $this->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<array<string,mixed>>
|
||||
*/
|
||||
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<string,mixed> $row
|
||||
* @param array<string,mixed>|null $shipment
|
||||
* @param array<string,string> $costs 条码 => 成本价
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string> $tradeNos
|
||||
* @return array<string,array{shipped_at:string,express_no:string,warehouse_name:string,amount:?string,freight:?string,lines:list<array{barcode:string,product_name:string,qty:int}>}>
|
||||
*/
|
||||
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<string> $barcodes
|
||||
* @return array<string,string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
134
modules/EcommerceFinance/View/PlantingLossPageData.php
Normal file
134
modules/EcommerceFinance/View/PlantingLossPageData.php
Normal file
@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\View;
|
||||
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository as Repository;
|
||||
use Modules\EcommerceFinance\Services\PlantingLossCalculator as Calculator;
|
||||
|
||||
/** 种菜数据汇总 → 种菜损耗:种菜报销明细匹配近两个月发货数据,拍A发B/拍A发A、只发虚拟条码/透明挂钩的不扣款,其余按成本价×1.2 扣款。 */
|
||||
class PlantingLossPageData
|
||||
{
|
||||
use PlantingPageFilters;
|
||||
|
||||
public const TITLE = '种菜损耗';
|
||||
|
||||
public const PER_PAGE = 50;
|
||||
|
||||
public const TEXT_FILTERS = [
|
||||
'work_order_no' => '工单编号',
|
||||
'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<string,string> */
|
||||
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<string,string> $filters
|
||||
* @param list<array<string,mixed>>|null $rows
|
||||
* @return \Generator<int,array<string,mixed>>
|
||||
*/
|
||||
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<string,int> $bucket @param array<string,mixed> $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);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
modules/EcommerceFinance/View/PlantingPageFilters.php
Normal file
73
modules/EcommerceFinance/View/PlantingPageFilters.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\View;
|
||||
|
||||
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository as Repository;
|
||||
|
||||
/** 种菜报销 / 种菜损耗 共用:报销月份、文本筛选、导出列。 */
|
||||
trait PlantingPageFilters
|
||||
{
|
||||
/** @return array<string,string> */
|
||||
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<string,mixed> $row @param array<string,string> $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<string,string> 可导出列(按请求顺序) */
|
||||
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 => '聚工单工单镜像尚未完成首次同步,完成后自动展示;当前为空不代表没有种菜报销。',
|
||||
};
|
||||
}
|
||||
}
|
||||
105
modules/EcommerceFinance/View/PlantingReimbursementPageData.php
Normal file
105
modules/EcommerceFinance/View/PlantingReimbursementPageData.php
Normal file
@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\View;
|
||||
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository as Repository;
|
||||
|
||||
/** 种菜数据汇总 → 种菜报销(聚工单打款工单“种菜报销”,已完成,完成时间自 2026-09-02 起)。 */
|
||||
class PlantingReimbursementPageData
|
||||
{
|
||||
use PlantingPageFilters;
|
||||
|
||||
public const TITLE = '种菜报销';
|
||||
|
||||
public const PER_PAGE = 50;
|
||||
|
||||
public const TEXT_FILTERS = [
|
||||
'work_order_no' => '工单编号',
|
||||
'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<string,string> */
|
||||
public function filters(): array
|
||||
{
|
||||
return $this->baseFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $filters
|
||||
* @param list<array<string,mixed>>|null $rows
|
||||
* @return \Generator<int,array<string,mixed>>
|
||||
*/
|
||||
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'),
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
<style>
|
||||
.pw-page{color:#171717;font-size:13px}.pw-heading{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:16px}.pw-heading p{font-size:12px;color:#595959;margin:0;line-height:1.7}.pw-tags{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}.pw-tag{display:inline-block;border-radius:4px;padding:4px 8px;background:#F7F0DE;color:#856632;white-space:nowrap;font-size:12px}.pw-tag.warn{color:#935F07}
|
||||
.pw-toolbar{display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px;margin:16px 0}.pw-toolbar label{display:grid;gap:6px;font-size:12px}.pw-toolbar input,.pw-toolbar select,.pw-toolbar button,.pw-button{height:34px;border:1px solid #D8D2C7;border-radius:8px;background:#fff;color:#171717;padding:0 10px;font-size:12px}.pw-toolbar input[type=text]{width:150px}.pw-toolbar select{max-width:260px}.pw-toolbar .pw-primary{background:#856632;border-color:#856632;color:#fff}.pw-button{display:inline-flex;align-items:center;text-decoration:none;cursor:pointer}.pw-toolbar :focus-visible,.pw-metrics a:focus-visible{outline:2px solid #856632;outline-offset:2px}
|
||||
.pw-metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin:16px 0}.pw-metrics>div,.pw-metrics>a{background:#fff;border:1px solid #D8D2C7;border-radius:12px;padding:16px;color:inherit;text-decoration:none;display:block}.pw-metrics>a:hover{border-color:#B9954F}.pw-metrics>a.is-active{border-color:#856632;box-shadow:inset 0 0 0 1px #856632}.pw-metrics span{color:#595959;font-size:12px}.pw-metrics strong{display:block;font-size:22px;margin-top:8px;font-variant-numeric:tabular-nums}.pw-metrics small{display:block;margin-top:6px;color:#595959;font-size:12px;font-variant-numeric:tabular-nums}.pw-metrics .is-key strong{color:#856632}.pw-warn-text{color:#935F07;font-weight:600}
|
||||
.pw-rules{font-size:12px;color:#595959;margin:12px 0}.pw-rules summary{cursor:pointer;color:#856632}.pw-rules p{line-height:1.8;margin:8px 0}.pw-error{background:#F7F0DE;color:#B80000;padding:12px;border-radius:8px;margin:12px 0}.pw-warn{background:#FFF4E5;color:#935F07;padding:10px 12px;border-radius:8px;margin:12px 0;font-size:12px}
|
||||
.pw-panel{background:#fff;border:1px solid #D8D2C7;border-radius:12px;overflow:hidden}.pw-table-scroll{overflow:auto;max-height:65vh}.pw-panel table{width:max-content;min-width:100%;border-collapse:collapse;font-size:12px}.pw-panel th,.pw-panel td{padding:10px 12px;border-bottom:1px solid #D8D2C7;text-align:left;vertical-align:top}.pw-panel th{background:#F7F0DE;color:#595959;font-weight:500;white-space:nowrap;position:sticky;top:0;z-index:1}.pw-panel td{max-width:280px;overflow-wrap:anywhere}.pw-panel .pw-lines{white-space:pre-line}.pw-panel .pw-money{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}.pw-panel .pw-empty{text-align:center;padding:60px 16px;color:#595959;max-width:none}.pw-panel tbody tr:hover{background:#faf7ef}.pw-footer{padding:12px 16px;font-size:12px;color:#595959}.pw-footer nav{margin-top:12px}
|
||||
.pw-result{display:inline-block;border-radius:4px;padding:4px 8px;font-size:12px;white-space:nowrap;background:#F2F2F2;color:#595959}.pw-result.is-deduct{background:#F7F0DE;color:#856632;font-weight:600}.pw-result.is-exempt{background:#EEF4EA;color:#53793E}
|
||||
.pw-column-settings{position:relative}.pw-column-settings summary{cursor:pointer;list-style:none}.pw-column-menu{position:absolute;top:40px;right:0;width:240px;padding:12px;background:#fff;border:1px solid #D8D2C7;border-radius:8px;z-index:10;box-shadow:0 4px 16px #17171714}.pw-column-menu p{font-size:12px;color:#595959;margin:0 0 8px}.pw-column-menu [data-pw-column-list]{max-height:340px;overflow:auto}.pw-column-menu label{display:flex;align-items:center;gap:6px;padding:6px;cursor:grab}.pw-column-menu input{height:auto;accent-color:#856632}.pw-column-menu button{margin-top:8px}.pw-column-menu [role=status]{display:block;font-size:12px;color:#935F07}
|
||||
@media(max-width:700px){.pw-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.pw-heading{flex-direction:column;align-items:flex-start}.pw-tags{justify-content:flex-start}.pw-toolbar input[type=text],.pw-toolbar select{width:100%;max-width:none}.pw-toolbar label{width:100%}}
|
||||
</style>
|
||||
@push('body_end')
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.querySelector('.pw-page');
|
||||
if (!root) return;
|
||||
const list = root.querySelector('[data-pw-column-list]');
|
||||
const defaults = Array.from(list.children);
|
||||
const keys = defaults.map(item => item.dataset.columnKey);
|
||||
const tableRows = Array.from(root.querySelectorAll('table tr')).filter(row => row.children.length === keys.length && !row.querySelector('[colspan]'));
|
||||
const cells = tableRows.map(row => Object.fromEntries(keys.map((key, i) => [key, row.children[i]])));
|
||||
const storageKey = root.dataset.preferenceKey;
|
||||
const scrollArea = root.querySelector('.pw-table-scroll');
|
||||
const fitTable = () => scrollArea.style.maxHeight = Math.max(180, window.innerHeight - scrollArea.getBoundingClientRect().top - 80) + 'px';
|
||||
fitTable(); window.addEventListener('resize', fitTable);
|
||||
const apply = (persist = true) => {
|
||||
const order = Array.from(list.children).map(item => item.dataset.columnKey);
|
||||
const visible = Array.from(list.querySelectorAll('input:checked')).map(input => input.value);
|
||||
tableRows.forEach((row, i) => order.forEach(key => { cells[i][key].hidden = !visible.includes(key); row.append(cells[i][key]); }));
|
||||
root.querySelectorAll('td[colspan]').forEach(cell => cell.colSpan = visible.length);
|
||||
root.querySelectorAll('[data-pw-export]').forEach(link => { const url = new URL(link.href); url.searchParams.set('columns', order.filter(key => visible.includes(key)).join(',')); link.href = url.toString(); });
|
||||
if (persist) try { localStorage.setItem(storageKey, JSON.stringify({order, visible})); } catch (_) {}
|
||||
};
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(storageKey));
|
||||
if (saved && Array.isArray(saved.order) && Array.isArray(saved.visible) && saved.order.length === keys.length && new Set(saved.order).size === keys.length && saved.order.every(key => keys.includes(key)) && saved.visible.some(key => keys.includes(key))) {
|
||||
saved.order.forEach(key => list.append(defaults.find(item => item.dataset.columnKey === key)));
|
||||
list.querySelectorAll('input').forEach(input => input.checked = saved.visible.includes(input.value));
|
||||
}
|
||||
} catch (_) {}
|
||||
apply(false);
|
||||
list.addEventListener('change', event => {
|
||||
if (!list.querySelector('input:checked')) { event.target.checked = true; root.querySelector('[data-pw-column-message]').textContent = '请至少保留一列。'; return; }
|
||||
root.querySelector('[data-pw-column-message]').textContent = ''; apply();
|
||||
});
|
||||
let dragging = null;
|
||||
list.addEventListener('dragstart', event => { dragging = event.target.closest('[data-column-key]'); if (dragging) { event.dataTransfer.setData('text/plain', dragging.dataset.columnKey); event.dataTransfer.effectAllowed = 'move'; } });
|
||||
list.addEventListener('dragover', event => event.preventDefault());
|
||||
list.addEventListener('drop', event => { event.preventDefault(); const target = event.target.closest('[data-column-key]'); if (dragging && target && dragging !== target) { list.insertBefore(dragging, target); apply(); } dragging = null; });
|
||||
root.querySelector('[data-pw-reset]').addEventListener('click', () => { defaults.forEach(item => { item.querySelector('input').checked = true; list.append(item); }); apply(); });
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
@ -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
|
||||
<div class="pw-page" data-preference-key="planting-loss:{{ company_id() }}:{{ optional(user())->id }}">
|
||||
<div class="pw-heading">
|
||||
<p>{{ $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 扣款。</p>
|
||||
<div class="pw-tags">
|
||||
<span class="pw-tag {{ $plantingSync['code'] === 'ready' ? '' : 'warn' }}">{{ $syncLabels[$plantingSync['code']] ?? $plantingSync['code'] }}@if($plantingSync['last_succeeded_at']) · {{ \Illuminate\Support\Str::of($plantingSync['last_succeeded_at'])->substr(0, 16) }}@endif</span>
|
||||
<span class="pw-tag {{ ($plantingShippingSync['code'] ?? '') === 'succeeded' ? '' : 'warn' }}">{{ $shippingLabels[$plantingShippingSync['code'] ?? 'unavailable'] ?? $plantingShippingSync['code'] }}@if($plantingShippingSync['last_succeeded_at'] ?? null) · {{ \Illuminate\Support\Str::of($plantingShippingSync['last_succeeded_at'])->substr(0, 16) }}@endif</span>
|
||||
</div>
|
||||
</div>
|
||||
<form method="get" class="pw-toolbar">
|
||||
<label>报销月份<select name="month">@foreach($plantingMonths as $month)<option value="{{ $month }}" @selected($plantingFilters['month'] === $month)>{{ $month }}</option>@endforeach</select></label>
|
||||
<label>损耗判定<select name="result">
|
||||
@foreach(Calculator::RESULTS as $result)<option value="{{ $result }}" @selected($plantingFilters['result'] === $result)>{{ $result }}</option>@endforeach
|
||||
<option value="all" @selected($plantingFilters['result'] === 'all')>全部</option>
|
||||
</select></label>
|
||||
@foreach(PageData::TEXT_FILTERS as $key => $label)
|
||||
<label>{{ $label }}<input type="text" name="{{ $key }}" value="{{ $plantingFilters[$key] }}" maxlength="200" placeholder="筛选{{ $label }}"></label>
|
||||
@endforeach
|
||||
<button type="submit" class="pw-primary">查询</button>
|
||||
<a class="pw-button" href="{{ request()->url() }}">重置</a>
|
||||
<a class="pw-button" data-pw-export href="{{ route('business.planting-data.planting-loss.export', $exportQuery) }}">导出当前筛选</a>
|
||||
<a class="pw-button" data-pw-export href="{{ route('business.planting-data.planting-loss.export', ['month' => $plantingFilters['month'], 'result' => Calculator::RESULT_EXEMPT]) }}">导出拍A发B(发运营)</a>
|
||||
<details class="pw-column-settings"><summary class="pw-button">列设置</summary><div class="pw-column-menu"><p>勾选显示,拖动调整顺序</p><div data-pw-column-list>@foreach($columns as $key => $label)<label draggable="true" data-column-key="{{ $key }}"><input type="checkbox" value="{{ $key }}" checked> {{ $label }}</label>@endforeach</div><button type="button" class="pw-button" data-pw-reset>恢复默认</button><span data-pw-column-message role="status"></span></div></details>
|
||||
</form>
|
||||
@php $deduct = $plantingSummary[Calculator::RESULT_DEDUCT]; $exempt = $plantingSummary[Calculator::RESULT_EXEMPT]; $free = $plantingSummary[Calculator::RESULT_FREE_GOODS]; $unmatched = $plantingSummary[Calculator::RESULT_UNMATCHED]; @endphp
|
||||
<div class="pw-metrics">
|
||||
<div><span>当月种菜报销</span><strong>{{ number_format($plantingSummary['total']) }}</strong><small>匹配到发货 {{ number_format($deduct['count'] + $exempt['count'] + $free['count']) }} 单</small></div>
|
||||
<a href="{{ $resultUrl(Calculator::RESULT_DEDUCT) }}" class="is-key {{ $plantingFilters['result'] === Calculator::RESULT_DEDUCT ? 'is-active' : '' }}"><span>扣款金额(元)</span><strong>{{ $money($deduct['deduction_amount']) }}</strong><small>扣款 {{ number_format($deduct['count']) }} 单@if($deduct['cost_missing'] > 0) · <b class="pw-warn-text">{{ number_format($deduct['cost_missing']) }} 单成本价缺失</b>@endif</small></a>
|
||||
<a href="{{ $resultUrl(Calculator::RESULT_EXEMPT) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_EXEMPT ? 'is-active' : '' }}"><span>不扣款(拍A发B/拍A发A)</span><strong>{{ number_format($exempt['count']) }}</strong><small>需发运营 · 最终报销 {{ $money($exempt['reimbursement_amount']) }}</small></a>
|
||||
<a href="{{ $resultUrl(Calculator::RESULT_FREE_GOODS) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_FREE_GOODS ? 'is-active' : '' }}"><span>不扣款(虚拟条码/透明挂钩)</span><strong>{{ number_format($free['count']) }}</strong><small>最终报销 {{ $money($free['reimbursement_amount']) }}</small></a>
|
||||
<a href="{{ $resultUrl(Calculator::RESULT_UNMATCHED) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_UNMATCHED ? 'is-active' : '' }}"><span>未匹配发货(不计入)</span><strong>{{ number_format($unmatched['count']) }}</strong><small>最终报销 {{ $money($unmatched['reimbursement_amount']) }}</small></a>
|
||||
</div>
|
||||
<details class="pw-rules"><summary>计算口径</summary><p>① 取该月种菜报销明细:工单类型“种菜报销”、状态“已完成”,完成时间 M 月 2 日 00:00 至次月 1 日 23:59:59(与「种菜报销」页同一口径)。② 用线上订单号(交易单号)精确匹配「快递应付 → 发货数据」中发货时间在上月 1 日至本月末的交易单号,匹配到的保留,同一单多个包裹/商品合并为一行。③ 匹配到的单中,任务备注或卖家备注写了“拍A发B”或“拍A发A”(不区分全半角、大小写和空格)的不做扣款,单独列出发给运营。④ 实际只发了“信誉订单虚拟条码”“透明挂钩”这类商品的不扣款;同一单里这类商品也不计入扣款。⑤ 其余匹配到的为扣款项:扣款金额 = Σ 发货条形码(商品编码)在「普通商品资料」中的成本价 × 发货数量 × 1.2(成本价 + 成本价×20%);普通商品资料里没有成本价(空或 0)的商品标为“成本价缺失”,不计入扣款金额,需补齐成本价后自动重算。未匹配到发货的单不计入种菜损耗,仅供核对。</p></details>
|
||||
@if(($plantingShippingSync['code'] ?? '') !== 'succeeded' && ! $plantingError)<div class="pw-warn">发货数据当前状态为“{{ $shippingLabels[$plantingShippingSync['code'] ?? 'unavailable'] ?? $plantingShippingSync['code'] }}”{{ ($plantingShippingSync['warning'] ?? '') !== '' ? ':' . $plantingShippingSync['warning'] : '' }},匹配结果可能不完整。</div>@endif
|
||||
@if($plantingError)<div class="pw-error" role="alert">{{ $plantingError }}</div>@endif
|
||||
<section class="pw-panel" aria-label="种菜损耗明细">
|
||||
<div class="pw-table-scroll"><table><thead><tr>
|
||||
@foreach($columns as $key => $label)<th data-column-key="{{ $key }}">{{ $label }}</th>@endforeach
|
||||
</tr></thead><tbody>
|
||||
@forelse($plantingRows as $row)
|
||||
<tr>@foreach($columns as $key => $label)
|
||||
@if(in_array($key, PageData::MONEY_COLUMNS, true))
|
||||
<td class="pw-money">{{ $row[$key] === null ? '—' : number_format((float) $row[$key], 2) }}</td>
|
||||
@elseif($key === 'loss_result')
|
||||
<td><span class="pw-result {{ $resultClasses[$row['loss_result']] ?? '' }}">{{ $row['loss_result'] }}</span></td>
|
||||
@else
|
||||
<td class="{{ str_contains((string) ($row[$key] ?? ''), "\n") ? 'pw-lines' : '' }}">{{ ($row[$key] ?? '') !== '' ? $row[$key] : '—' }}</td>
|
||||
@endif
|
||||
@endforeach</tr>
|
||||
@empty
|
||||
<tr><td colspan="{{ count($columns) }}" class="pw-empty">{{ $plantingError ?: '当前筛选条件下没有种菜损耗明细。' }}</td></tr>
|
||||
@endforelse
|
||||
</tbody></table></div>
|
||||
<div class="pw-footer">共 {{ number_format($plantingRows->total()) }} 条,每页 {{ PageData::PER_PAGE }} 条。@if($plantingFilters['result'] !== 'all' && $plantingRows->total() > 0)当前筛选:扣款金额 {{ $money($plantingSummary['filtered']['deduction_amount']) }} 元,最终报销 {{ $money($plantingSummary['filtered']['reimbursement_amount']) }} 元。@endif{{ $plantingRows->links() }}</div>
|
||||
</section>
|
||||
</div>
|
||||
@include('business.pages.billing-transactions.partials.planting-work-order-assets')
|
||||
@ -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
|
||||
<div class="pw-page" data-preference-key="planting-reimbursements:{{ company_id() }}:{{ optional(user())->id }}">
|
||||
<div class="pw-heading">
|
||||
<p>聚水潭聚工单「打款工单 → 种菜报销」中工单状态为已完成的明细,按完成时间归属报销月份:{{ $plantingFilters['month'] }} 月 = 完成时间 {{ substr($plantingWindow['from'], 0, 16) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingWindow['to'])->subSecond()->format('Y-m-d H:i:s') }}(当天报销前一天的种菜)。</p>
|
||||
<div class="pw-tags"><span class="pw-tag {{ $plantingSync['code'] === 'ready' ? '' : 'warn' }}">{{ $syncLabels[$plantingSync['code']] ?? $plantingSync['code'] }}@if($plantingSync['last_succeeded_at']) · {{ \Illuminate\Support\Str::of($plantingSync['last_succeeded_at'])->substr(0, 16) }}@endif</span></div>
|
||||
</div>
|
||||
<form method="get" class="pw-toolbar">
|
||||
<label>报销月份<select name="month">@foreach($plantingMonths as $month)<option value="{{ $month }}" @selected($plantingFilters['month'] === $month)>{{ $month }}</option>@endforeach</select></label>
|
||||
@foreach(PageData::TEXT_FILTERS as $key => $label)
|
||||
<label>{{ $label }}<input type="text" name="{{ $key }}" value="{{ $plantingFilters[$key] }}" maxlength="200" placeholder="筛选{{ $label }}"></label>
|
||||
@endforeach
|
||||
<button type="submit" class="pw-primary">查询</button>
|
||||
<a class="pw-button" href="{{ request()->url() }}">重置</a>
|
||||
<a class="pw-button" data-pw-export href="{{ route('business.planting-data.planting-reimbursements.export', $exportQuery) }}">导出当前筛选</a>
|
||||
<details class="pw-column-settings"><summary class="pw-button">列设置</summary><div class="pw-column-menu"><p>勾选显示,拖动调整顺序</p><div data-pw-column-list>@foreach($columns as $key => $label)<label draggable="true" data-column-key="{{ $key }}"><input type="checkbox" value="{{ $key }}" checked> {{ $label }}</label>@endforeach</div><button type="button" class="pw-button" data-pw-reset>恢复默认</button><span data-pw-column-message role="status"></span></div></details>
|
||||
</form>
|
||||
<div class="pw-metrics">
|
||||
<div><span>种菜报销工单</span><strong>{{ number_format($plantingSummary['count']) }}</strong></div>
|
||||
<div><span>任务付款金额(元)</span><strong>{{ number_format($plantingSummary['task_payment'] / 100, 2) }}</strong></div>
|
||||
<div><span>任务佣金(元)</span><strong>{{ number_format($plantingSummary['task_commission'] / 100, 2) }}</strong></div>
|
||||
<div><span>任务空包费(元)</span><strong>{{ number_format($plantingSummary['task_empty_package_fee'] / 100, 2) }}</strong></div>
|
||||
<div class="is-key"><span>最终报销金额(元)</span><strong>{{ number_format($plantingSummary['reimbursement_amount'] / 100, 2) }}</strong></div>
|
||||
</div>
|
||||
<details class="pw-rules"><summary>接入口径</summary><p>工单类型为“种菜报销”、工单状态为“已完成”,按完成时间筛选,自 2026-09-02 00:00 起接入。种菜报销都是当天报销前一天的种菜明细,所以 M 月的种菜报销明细 = 完成时间 M 月 2 日 00:00 至次月 1 日 23:59:59。同一工单号只取最新一版,完成后被作废的不计入。数据只读自聚工单镜像,约每 10 分钟随镜像同步更新,不在此修改。</p></details>
|
||||
@if($plantingError)<div class="pw-error" role="alert">{{ $plantingError }}</div>@endif
|
||||
<section class="pw-panel" aria-label="种菜报销明细">
|
||||
<div class="pw-table-scroll"><table><thead><tr>
|
||||
@foreach($columns as $key => $label)<th data-column-key="{{ $key }}">{{ $label }}</th>@endforeach
|
||||
</tr></thead><tbody>
|
||||
@forelse($plantingRows as $row)
|
||||
<tr>@foreach($columns as $key => $label)
|
||||
@if(in_array($key, PageData::MONEY_COLUMNS, true))
|
||||
<td class="pw-money">{{ $row[$key] === null ? '—' : number_format((float) $row[$key], 2) }}</td>
|
||||
@else
|
||||
<td class="{{ str_contains((string) ($row[$key] ?? ''), "\n") ? 'pw-lines' : '' }}">{{ ($row[$key] ?? '') !== '' ? $row[$key] : '—' }}</td>
|
||||
@endif
|
||||
@endforeach</tr>
|
||||
@empty
|
||||
<tr><td colspan="{{ count($columns) }}" class="pw-empty">{{ $plantingError ?: '当前筛选条件下暂无种菜报销,请调整筛选条件。' }}</td></tr>
|
||||
@endforelse
|
||||
</tbody></table></div>
|
||||
<div class="pw-footer">共 {{ number_format($plantingRows->total()) }} 条,每页 {{ PageData::PER_PAGE }} 条。{{ $plantingRows->links() }}</div>
|
||||
</section>
|
||||
</div>
|
||||
@include('business.pages.billing-transactions.partials.planting-work-order-assets')
|
||||
@ -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.')
|
||||
|
||||
113
tests/Feature/Business/PlantingWorkOrderPagesTest.php
Normal file
113
tests/Feature/Business/PlantingWorkOrderPagesTest.php
Normal file
@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository;
|
||||
use Modules\EcommerceFinance\Services\PlantingLossCalculator as Calculator;
|
||||
use Modules\EcommerceFinance\View\PlantingLossPageData;
|
||||
use Modules\EcommerceFinance\View\PlantingReimbursementPageData;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class PlantingWorkOrderPagesTest extends FeatureTestCase
|
||||
{
|
||||
private function row(array $patch = []): array
|
||||
{
|
||||
$row = array_fill_keys(array_keys(PlantingLossPageData::COLUMNS + PlantingReimbursementPageData::COLUMNS), '');
|
||||
|
||||
return array_replace($row, [
|
||||
'completed_at' => '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());
|
||||
}
|
||||
}
|
||||
217
tests/Unit/EcommerceFinance/PlantingLossCalculatorTest.php
Normal file
217
tests/Unit/EcommerceFinance/PlantingLossCalculatorTest.php
Normal file
@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository as Repository;
|
||||
use Modules\EcommerceFinance\Services\PlantingLossCalculator as Calculator;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PlantingLossCalculatorTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'database.connections.business_mirror' => ['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']);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user