每日商品仓储费明细 #187
@ -37,7 +37,7 @@ class ThirdPartyWarehouseStorageFeeCalculator
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{summary:array<string,mixed>,daily:list<array<string,mixed>>,per_sku:list<array<string,mixed>>}
|
||||
* @return array{summary:array<string,mixed>,daily:list<array<string,mixed>>,per_sku:list<array<string,mixed>>,daily_sku:list<array<string,mixed>>}
|
||||
*/
|
||||
public function calculate(int $warehouseId, string $warehouseName, string $period): array
|
||||
{
|
||||
@ -63,6 +63,10 @@ class ThirdPartyWarehouseStorageFeeCalculator
|
||||
$daily = [];
|
||||
/** @var array<string,array<string,mixed>> $skus */
|
||||
$skus = [];
|
||||
$dailySku = [];
|
||||
$storagePerPiecePerDay = $days > 0 && $piecesPerPallet > 0
|
||||
? $palletArea * $sqmRate / $piecesPerPallet / $days
|
||||
: 0.0;
|
||||
|
||||
foreach ($this->mirror->exportRows(['dimension' => 'month', 'period' => $period], 5000) as $row) {
|
||||
$date = trim((string) ($row['date'] ?? ''));
|
||||
@ -90,6 +94,20 @@ class ThirdPartyWarehouseStorageFeeCalculator
|
||||
$daily[$date]['inbound_qty'] += $inboundQty;
|
||||
$daily[$date]['outbound_qty'] += $outboundQty;
|
||||
|
||||
$dailySku[] = [
|
||||
'date' => $date,
|
||||
'owner' => (string) ($row['owner'] ?? ''),
|
||||
'sku_code' => $code,
|
||||
'product_name' => (string) ($row['product_name'] ?? ''),
|
||||
'supplier' => (string) ($row['supplier'] ?? ''),
|
||||
'category' => (string) ($row['category'] ?? ''),
|
||||
'unit' => (string) ($row['unit'] ?? ''),
|
||||
'opening' => round($opening, 2),
|
||||
'net_movement' => round($closing - $opening, 2),
|
||||
'closing' => round($closing, 2),
|
||||
'storage_fee' => round($closing * $storagePerPiecePerDay, 6),
|
||||
];
|
||||
|
||||
if (! isset($skus[$code])) {
|
||||
$skus[$code] = [
|
||||
'owner' => (string) ($row['owner'] ?? ''),
|
||||
@ -198,6 +216,7 @@ class ThirdPartyWarehouseStorageFeeCalculator
|
||||
],
|
||||
'daily' => $dailyRows,
|
||||
'per_sku' => $perSku,
|
||||
'daily_sku' => $dailySku,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,13 +11,14 @@ use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
|
||||
/**
|
||||
* 三方仓仓储费核算明细导出:汇总 / 进销存数据汇总(每日) / 进销存(逐商品) 三张表。
|
||||
* 三方仓仓储费核算明细导出:汇总 / 进销存数据汇总(每日) / 进销存(逐商品) /
|
||||
* 每日商品仓储费四张表。
|
||||
* 数据来自 ThirdPartyWarehouseStorageFeeCalculator 的核算结果(或存档快照)。
|
||||
*/
|
||||
class ThirdPartyWarehouseStorageSettlementExport implements WithMultipleSheets
|
||||
{
|
||||
/**
|
||||
* @param array{summary:array<string,mixed>,daily:list<array<string,mixed>>,per_sku:list<array<string,mixed>>} $result
|
||||
* @param array{summary:array<string,mixed>,daily:list<array<string,mixed>>,per_sku:list<array<string,mixed>>,daily_sku?:list<array<string,mixed>>} $result
|
||||
*/
|
||||
public function __construct(private readonly array $result)
|
||||
{
|
||||
@ -30,6 +31,7 @@ class ThirdPartyWarehouseStorageSettlementExport implements WithMultipleSheets
|
||||
new StorageSettlementSummarySheet($this->result['summary']),
|
||||
new StorageSettlementDailySheet($this->result['summary'], $this->result['daily']),
|
||||
new StorageSettlementSkuSheet($this->result['per_sku']),
|
||||
new StorageSettlementDailySkuSheet($this->result['daily_sku'] ?? []),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -184,3 +186,43 @@ class StorageSettlementSkuSheet implements FromArray, WithHeadings, WithTitle
|
||||
], $this->perSku);
|
||||
}
|
||||
}
|
||||
|
||||
class StorageSettlementDailySkuSheet implements FromArray, WithHeadings, WithTitle
|
||||
{
|
||||
/** @param list<array<string,mixed>> $dailySku */
|
||||
public function __construct(private readonly array $dailySku)
|
||||
{
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return '每日商品仓储费';
|
||||
}
|
||||
|
||||
/** @return array<int,string> */
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'日期', '货主', '商品编码', '商品名称', '供应商', '分类', '单位',
|
||||
'上日结余', '当日进出净额', '当日库存结余', '当天仓储费',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int,array<int,mixed>> */
|
||||
public function array(): array
|
||||
{
|
||||
return array_map(static fn (array $row): array => [
|
||||
$row['date'],
|
||||
$row['owner'],
|
||||
$row['sku_code'],
|
||||
$row['product_name'],
|
||||
$row['supplier'],
|
||||
$row['category'],
|
||||
$row['unit'],
|
||||
(float) $row['opening'],
|
||||
(float) $row['net_movement'],
|
||||
(float) $row['closing'],
|
||||
(float) $row['storage_fee'],
|
||||
], $this->dailySku);
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,6 +45,15 @@ class ThirdPartyWarehouseQuotes extends Component
|
||||
|
||||
public ?string $settlementError = null;
|
||||
|
||||
public ?string $settlementDetailPeriod = null;
|
||||
|
||||
public string $settlementDetailDate = '';
|
||||
|
||||
/** @var list<string> */
|
||||
public array $settlementDetailDates = [];
|
||||
|
||||
public ?string $settlementDetailMessage = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->effectiveAt = now()->toDateString();
|
||||
@ -130,7 +139,11 @@ class ThirdPartyWarehouseQuotes extends Component
|
||||
'loss_fee' => $summary['loss_fee'],
|
||||
'compensation_fee' => $summary['compensation_fee'],
|
||||
'total_fee' => $summary['total_fee'],
|
||||
'detail_json' => ['daily' => $result['daily'], 'per_sku' => $result['per_sku']],
|
||||
'detail_json' => [
|
||||
'daily' => $result['daily'],
|
||||
'per_sku' => $result['per_sku'],
|
||||
'daily_sku' => $result['daily_sku'],
|
||||
],
|
||||
'calculated_by' => (int) user_id(),
|
||||
],
|
||||
);
|
||||
@ -144,6 +157,7 @@ class ThirdPartyWarehouseQuotes extends Component
|
||||
$this->ensureAuthenticated();
|
||||
|
||||
$settlement = ThirdPartyWarehouseStorageSettlement::query()
|
||||
->where('company_id', (int) company_id())
|
||||
->where('warehouse_id', self::WAREHOUSE_ID)
|
||||
->where('period', $period)
|
||||
->first();
|
||||
@ -154,11 +168,12 @@ class ThirdPartyWarehouseQuotes extends Component
|
||||
return null;
|
||||
}
|
||||
|
||||
$detail = $settlement->detail_json ?: ['daily' => [], 'per_sku' => []];
|
||||
$detail = $settlement->detail_json ?: ['daily' => [], 'per_sku' => [], 'daily_sku' => []];
|
||||
$result = [
|
||||
'summary' => $this->summaryFromSettlement($settlement),
|
||||
'daily' => $detail['daily'] ?? [],
|
||||
'per_sku' => $detail['per_sku'] ?? [],
|
||||
'daily_sku' => $detail['daily_sku'] ?? [],
|
||||
];
|
||||
|
||||
$fileName = $settlement->warehouse_name . '仓储费用_' . $period . '.xlsx';
|
||||
@ -166,11 +181,51 @@ class ThirdPartyWarehouseQuotes extends Component
|
||||
return Excel::download(new ThirdPartyWarehouseStorageSettlementExport($result), $fileName);
|
||||
}
|
||||
|
||||
public function viewSettlementDetails(string $period): void
|
||||
{
|
||||
$this->ensureAuthenticated();
|
||||
abort_unless(preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $period) === 1, 422);
|
||||
|
||||
$this->settlementDetailPeriod = $period;
|
||||
$this->settlementDetailDate = '';
|
||||
$this->settlementDetailDates = [];
|
||||
$this->settlementDetailMessage = null;
|
||||
|
||||
$settlement = $this->settlementForPeriod($period);
|
||||
if ($settlement === null) {
|
||||
$this->settlementDetailMessage = '未找到 ' . $period . ' 的核算存档。';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$detail = $settlement->detail_json ?: [];
|
||||
$dailySku = $detail['daily_sku'] ?? [];
|
||||
if ($dailySku === []) {
|
||||
$this->settlementDetailMessage = $period . ' 的原存档没有每日商品明细,请重新核算该月份后查看。';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$dates = array_values(array_unique(array_column($dailySku, 'date')));
|
||||
sort($dates);
|
||||
$this->settlementDetailDates = $dates;
|
||||
$this->settlementDetailDate = $dates[0] ?? '';
|
||||
}
|
||||
|
||||
public function closeSettlementDetails(): void
|
||||
{
|
||||
$this->settlementDetailPeriod = null;
|
||||
$this->settlementDetailDate = '';
|
||||
$this->settlementDetailDates = [];
|
||||
$this->settlementDetailMessage = null;
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
$data = $this->repository()->pageData();
|
||||
|
||||
$history = ThirdPartyWarehouseStorageSettlement::query()
|
||||
->where('company_id', (int) company_id())
|
||||
->where('warehouse_id', self::WAREHOUSE_ID)
|
||||
->orderByDesc('period')
|
||||
->limit(24)
|
||||
@ -184,12 +239,15 @@ class ThirdPartyWarehouseQuotes extends Component
|
||||
'calculated_at' => optional($row->updated_at)->format('Y-m-d H:i'),
|
||||
])
|
||||
->all();
|
||||
$settlementDetail = $this->settlementDetailData();
|
||||
|
||||
return view('business.pages.courier-payables.partials.third-party-warehouse-quotes', [
|
||||
'warehouseQuotes' => $data['rows'],
|
||||
'warehouseQuoteSummary' => $data['summary'],
|
||||
'warehouseQuotesReady' => $data['ready'],
|
||||
'settlementHistory' => $history,
|
||||
'settlementDetailRows' => $settlementDetail['rows'],
|
||||
'settlementDetailSummary' => $settlementDetail['summary'],
|
||||
]);
|
||||
}
|
||||
|
||||
@ -219,6 +277,56 @@ class ThirdPartyWarehouseQuotes extends Component
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细行只参与当前服务端渲染,不进入 Livewire 公共快照,避免日期切换时回传整张表。
|
||||
*
|
||||
* @return array{rows:list<array<string,mixed>>,summary:array<string,float|int>|null}
|
||||
*/
|
||||
private function settlementDetailData(): array
|
||||
{
|
||||
$empty = ['rows' => [], 'summary' => null];
|
||||
|
||||
if ($this->settlementDetailPeriod === null
|
||||
|| ! in_array($this->settlementDetailDate, $this->settlementDetailDates, true)) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$settlement = $this->settlementForPeriod($this->settlementDetailPeriod);
|
||||
if ($settlement === null) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$detail = $settlement->detail_json ?: [];
|
||||
$rows = array_values(array_filter(
|
||||
$detail['daily_sku'] ?? [],
|
||||
fn (array $row): bool => ($row['date'] ?? null) === $this->settlementDetailDate,
|
||||
));
|
||||
usort($rows, static fn (array $left, array $right): int => strcmp(
|
||||
(string) ($left['sku_code'] ?? ''),
|
||||
(string) ($right['sku_code'] ?? ''),
|
||||
));
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'summary' => [
|
||||
'sku_count' => count($rows),
|
||||
'opening' => array_sum(array_column($rows, 'opening')),
|
||||
'net_movement' => array_sum(array_column($rows, 'net_movement')),
|
||||
'closing' => array_sum(array_column($rows, 'closing')),
|
||||
'storage_fee' => array_sum(array_column($rows, 'storage_fee')),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function settlementForPeriod(string $period): ?ThirdPartyWarehouseStorageSettlement
|
||||
{
|
||||
return ThirdPartyWarehouseStorageSettlement::query()
|
||||
->where('company_id', (int) company_id())
|
||||
->where('warehouse_id', self::WAREHOUSE_ID)
|
||||
->where('period', $period)
|
||||
->first();
|
||||
}
|
||||
|
||||
/** @return array<string,array<int,string>> */
|
||||
protected function rules(): array
|
||||
{
|
||||
|
||||
@ -26,10 +26,10 @@
|
||||
.warehouse-quote-field { display:grid; min-width:0; gap:.35rem; }
|
||||
.warehouse-quote-field--wide { grid-column:span 2; }
|
||||
.warehouse-quote-field label { color:var(--quote-muted); font-size:.75rem; font-weight:600; }
|
||||
.warehouse-quote-field input,.warehouse-quote-field textarea { width:100%; min-width:0; border:1px solid var(--quote-border); border-radius:.5rem; background:#fff; color:var(--quote-ink); font:inherit; }
|
||||
.warehouse-quote-field input { height:34px; padding:0 .625rem; }
|
||||
.warehouse-quote-field input,.warehouse-quote-field select,.warehouse-quote-field textarea { width:100%; min-width:0; border:1px solid var(--quote-border); border-radius:.5rem; background:#fff; color:var(--quote-ink); font:inherit; }
|
||||
.warehouse-quote-field input,.warehouse-quote-field select { height:34px; padding:0 .625rem; }
|
||||
.warehouse-quote-field textarea { min-height:34px; padding:.5rem .625rem; resize:vertical; }
|
||||
.warehouse-quote-field input:focus,.warehouse-quote-field textarea:focus { border-color:var(--quote-accent); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
|
||||
.warehouse-quote-field input:focus,.warehouse-quote-field select:focus,.warehouse-quote-field textarea:focus { border-color:var(--quote-accent); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
|
||||
.warehouse-quote-formulas { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:1px; margin:0 1rem 1rem; overflow:hidden; border:1px solid var(--quote-border); border-radius:.5rem; background:var(--quote-border); }
|
||||
.warehouse-quote-formula { min-width:0; padding:.7rem .75rem; background:#FCFAF5; }
|
||||
.warehouse-quote-formula dt { color:var(--quote-muted); font-size:.7rem; font-weight:600; }
|
||||
@ -66,8 +66,34 @@
|
||||
.warehouse-quote-card dt { color:var(--quote-muted); font-size:.7rem; font-weight:600; }
|
||||
.warehouse-quote-card dd { margin:.2rem 0 0; font-size:1rem; font-weight:700; font-variant-numeric:tabular-nums; }
|
||||
.warehouse-quote-card--total dd { color:var(--quote-accent); }
|
||||
.warehouse-settlement-actions { display:flex; align-items:center; gap:.65rem; }
|
||||
.warehouse-settlement-detail { border-top:1px solid var(--quote-border); }
|
||||
.warehouse-settlement-detail__head { display:flex; align-items:flex-end; justify-content:space-between; gap:1rem; padding:1rem; }
|
||||
.warehouse-settlement-detail__title { display:flex; min-width:0; align-items:center; gap:.55rem; }
|
||||
.warehouse-settlement-detail__title .material-icons-outlined { color:var(--quote-accent); font-size:20px; }
|
||||
.warehouse-settlement-detail__controls { display:flex; align-items:flex-end; gap:.65rem; }
|
||||
.warehouse-settlement-detail__close { display:grid; width:34px; height:34px; place-items:center; border:1px solid var(--quote-border); border-radius:.5rem; background:#fff; color:var(--quote-muted); cursor:pointer; }
|
||||
.warehouse-settlement-detail__close:hover { border-color:var(--quote-accent); background:#FCFAF5; color:var(--quote-ink); }
|
||||
.warehouse-settlement-detail__close:focus-visible { outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
|
||||
.warehouse-settlement-detail__close .material-icons-outlined { font-size:18px; }
|
||||
.warehouse-settlement-detail__summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); border-top:1px solid var(--quote-border); border-bottom:1px solid var(--quote-border); background:#FCFAF5; }
|
||||
.warehouse-settlement-detail__metric { min-width:0; padding:.65rem 1rem; border-left:1px solid var(--quote-border); }
|
||||
.warehouse-settlement-detail__metric:first-child { border-left:0; }
|
||||
.warehouse-settlement-detail__metric dt { color:var(--quote-muted); font-size:.7rem; font-weight:600; }
|
||||
.warehouse-settlement-detail__metric dd { margin:.15rem 0 0; font-size:.875rem; font-weight:600; font-variant-numeric:tabular-nums; }
|
||||
.warehouse-settlement-detail__table-wrap { max-height:min(32rem,65vh); overflow:auto; }
|
||||
.warehouse-settlement-detail__table { min-width:76rem; }
|
||||
.warehouse-settlement-detail__table th { position:sticky; top:0; z-index:1; }
|
||||
.warehouse-settlement-detail__notice { margin:0; padding:1rem; border-top:1px solid var(--quote-border); color:var(--quote-muted); }
|
||||
@media (max-width:1050px){ .warehouse-quote-cards { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
@media (max-width:640px){ .warehouse-quote-cards { grid-template-columns:1fr; } }
|
||||
@media (max-width:760px){ .warehouse-settlement-detail__summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .warehouse-settlement-detail__metric { border-top:1px solid var(--quote-border); } }
|
||||
@media (max-width:640px){
|
||||
.warehouse-quote-cards,.warehouse-settlement-detail__summary { grid-template-columns:1fr; }
|
||||
.warehouse-settlement-detail__head { align-items:flex-start; flex-direction:column; }
|
||||
.warehouse-settlement-detail__controls { width:100%; }
|
||||
.warehouse-settlement-detail__controls .warehouse-quote-field { flex:1; max-width:none; }
|
||||
.warehouse-settlement-detail__metric { border-left:0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="warehouse-quote-intro" aria-label="仓储报价概览">
|
||||
@ -170,7 +196,7 @@
|
||||
<div class="warehouse-quote-form__head">
|
||||
<div>
|
||||
<h2 class="warehouse-quote-section-title" id="warehouse-settle-title">仓储费核算</h2>
|
||||
<div class="warehouse-quote-section-note">选择结算月份,一键按当期生效报价核算并存档,可导出核算明细(汇总/每日/逐商品)</div>
|
||||
<div class="warehouse-quote-section-note">选择结算月份,一键按当期生效报价核算并存档,可查看和导出每日商品仓储费</div>
|
||||
</div>
|
||||
</div>
|
||||
@if ($settlementError)
|
||||
@ -188,6 +214,9 @@
|
||||
<span class="material-icons-outlined" aria-hidden="true">calculate</span><span>核算仓储费</span>
|
||||
</button>
|
||||
@if ($settlementSummary)
|
||||
<button class="quote-button" type="button" wire:click="viewSettlementDetails('{{ $settlementSummary['period'] }}')" wire:loading.attr="disabled" wire:target="viewSettlementDetails">
|
||||
<span class="material-icons-outlined" aria-hidden="true">inventory_2</span><span>查看商品明细</span>
|
||||
</button>
|
||||
<button class="quote-button" type="button" wire:click="exportSettlement('{{ $settlementSummary['period'] }}')" wire:loading.attr="disabled" wire:target="exportSettlement">
|
||||
<span class="material-icons-outlined" aria-hidden="true">download</span><span>导出核算明细</span>
|
||||
</button>
|
||||
@ -219,7 +248,10 @@
|
||||
<td class="is-number"><strong>{{ number_format($item['total_fee'], 2) }}</strong></td>
|
||||
<td>{{ $item['calculated_at'] }}</td>
|
||||
<td>
|
||||
<button class="quote-action" type="button" wire:click="exportSettlement('{{ $item['period'] }}')" wire:loading.attr="disabled" wire:target="exportSettlement">导出</button>
|
||||
<div class="warehouse-settlement-actions">
|
||||
<button class="quote-action" type="button" wire:click="viewSettlementDetails('{{ $item['period'] }}')" wire:loading.attr="disabled" wire:target="viewSettlementDetails">查看明细</button>
|
||||
<button class="quote-action" type="button" wire:click="exportSettlement('{{ $item['period'] }}')" wire:loading.attr="disabled" wire:target="exportSettlement">导出</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
@ -228,6 +260,73 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if ($settlementDetailPeriod)
|
||||
<section class="warehouse-settlement-detail" aria-labelledby="warehouse-settlement-detail-title">
|
||||
<div class="warehouse-settlement-detail__head">
|
||||
<div class="warehouse-settlement-detail__title">
|
||||
<span class="material-icons-outlined" aria-hidden="true">inventory_2</span>
|
||||
<div>
|
||||
<h3 class="warehouse-quote-section-title" id="warehouse-settlement-detail-title">每日商品仓储费明细</h3>
|
||||
<div class="warehouse-quote-section-note">{{ $settlementDetailPeriod }} · 上月结转库存按商品编码逐日延续</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="warehouse-settlement-detail__controls">
|
||||
@if ($settlementDetailDates !== [])
|
||||
<div class="warehouse-quote-field">
|
||||
<label for="warehouse-settlement-detail-date">库存日期</label>
|
||||
<select id="warehouse-settlement-detail-date" wire:model.change="settlementDetailDate">
|
||||
@foreach ($settlementDetailDates as $date)
|
||||
<option value="{{ $date }}">{{ $date }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
<button class="warehouse-settlement-detail__close" type="button" wire:click="closeSettlementDetails" title="关闭明细" aria-label="关闭明细">
|
||||
<span class="material-icons-outlined" aria-hidden="true">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if ($settlementDetailMessage)
|
||||
<p class="warehouse-settlement-detail__notice" role="status">{{ $settlementDetailMessage }}</p>
|
||||
@elseif ($settlementDetailSummary)
|
||||
<dl class="warehouse-settlement-detail__summary" aria-label="当日商品仓储费汇总">
|
||||
<div class="warehouse-settlement-detail__metric"><dt>商品编码数</dt><dd>{{ number_format($settlementDetailSummary['sku_count']) }}</dd></div>
|
||||
<div class="warehouse-settlement-detail__metric"><dt>上日结余</dt><dd>{{ number_format($settlementDetailSummary['opening'], 2) }}</dd></div>
|
||||
<div class="warehouse-settlement-detail__metric"><dt>当日进出净额</dt><dd>{{ number_format($settlementDetailSummary['net_movement'], 2) }}</dd></div>
|
||||
<div class="warehouse-settlement-detail__metric"><dt>当日库存结余</dt><dd>{{ number_format($settlementDetailSummary['closing'], 2) }}</dd></div>
|
||||
<div class="warehouse-settlement-detail__metric"><dt>当天仓储费</dt><dd>{{ number_format($settlementDetailSummary['storage_fee'], 4) }}</dd></div>
|
||||
</dl>
|
||||
<div class="warehouse-quote-table-wrap warehouse-settlement-detail__table-wrap">
|
||||
<table class="warehouse-quote-table warehouse-settlement-detail__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>商品编码</th><th>商品名称</th><th>供应商</th><th>分类</th><th>单位</th>
|
||||
<th class="is-number">上日结余</th><th class="is-number">当日进出净额</th>
|
||||
<th class="is-number">当日库存结余</th><th class="is-number">当天仓储费</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($settlementDetailRows as $row)
|
||||
<tr wire:key="warehouse-detail-{{ $settlementDetailDate }}-{{ $row['sku_code'] }}">
|
||||
<td><strong>{{ $row['sku_code'] }}</strong></td>
|
||||
<td>{{ $row['product_name'] }}</td>
|
||||
<td>{{ $row['supplier'] }}</td>
|
||||
<td>{{ $row['category'] }}</td>
|
||||
<td>{{ $row['unit'] }}</td>
|
||||
<td class="is-number">{{ number_format($row['opening'], 2) }}</td>
|
||||
<td class="is-number">{{ number_format($row['net_movement'], 2) }}</td>
|
||||
<td class="is-number">{{ number_format($row['closing'], 2) }}</td>
|
||||
<td class="is-number">{{ number_format($row['storage_fee'], 6) }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td class="warehouse-quote-empty" colspan="9">该日期没有商品库存明细。</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
<section class="warehouse-quote-history" aria-labelledby="warehouse-quote-history-title">
|
||||
|
||||
@ -5,6 +5,7 @@ namespace Tests\Feature\Business;
|
||||
use App\Http\Livewire\Business\ThirdPartyWarehouseQuotes as WarehouseQuoteComponent;
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Models\Business\ThirdPartyWarehouseQuote;
|
||||
use App\Models\Business\ThirdPartyWarehouseStorageSettlement;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
@ -120,6 +121,57 @@ class ThirdPartyWarehouseQuotePageTest extends FeatureTestCase
|
||||
->call('setStatus', $quote->id, ThirdPartyWarehouseQuote::STATUS_INACTIVE);
|
||||
}
|
||||
|
||||
public function test_daily_sku_storage_detail_can_be_opened_and_switched_by_date(): void
|
||||
{
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$this->loginAs();
|
||||
$quote = $this->createQuote();
|
||||
$this->createSettlement($quote, [
|
||||
['date' => '2026-08-01', 'owner' => '甲', 'sku_code' => 'SKU-A', 'product_name' => '商品A', 'supplier' => '供应商甲', 'category' => '桌类', 'unit' => '件', 'opening' => 100, 'net_movement' => 0, 'closing' => 100, 'storage_fee' => 4.417952],
|
||||
['date' => '2026-08-01', 'owner' => '甲', 'sku_code' => 'SKU-B', 'product_name' => '商品B', 'supplier' => '供应商乙', 'category' => '柜类', 'unit' => '件', 'opening' => 50, 'net_movement' => 10, 'closing' => 60, 'storage_fee' => 2.650771],
|
||||
['date' => '2026-08-02', 'owner' => '甲', 'sku_code' => 'SKU-A', 'product_name' => '商品A', 'supplier' => '供应商甲', 'category' => '桌类', 'unit' => '件', 'opening' => 100, 'net_movement' => -10, 'closing' => 90, 'storage_fee' => 3.976157],
|
||||
]);
|
||||
|
||||
Livewire::test(WarehouseQuoteComponent::class)
|
||||
->call('viewSettlementDetails', '2026-08')
|
||||
->assertSet('settlementDetailDate', '2026-08-01')
|
||||
->assertSee('每日商品仓储费明细')
|
||||
->assertSee('160.00')
|
||||
->assertSee('SKU-A')
|
||||
->assertSee('SKU-B')
|
||||
->set('settlementDetailDate', '2026-08-02')
|
||||
->assertSee('90.00')
|
||||
->assertSee('3.976157')
|
||||
->assertDontSee('SKU-B');
|
||||
}
|
||||
|
||||
public function test_legacy_settlement_explains_that_recalculation_is_required(): void
|
||||
{
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$this->loginAs();
|
||||
$quote = $this->createQuote();
|
||||
$this->createSettlement($quote, []);
|
||||
|
||||
Livewire::test(WarehouseQuoteComponent::class)
|
||||
->call('viewSettlementDetails', '2026-08')
|
||||
->assertSee('原存档没有每日商品明细,请重新核算该月份后查看');
|
||||
}
|
||||
|
||||
public function test_settlement_history_detail_and_export_are_company_scoped(): void
|
||||
{
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
$this->loginAs();
|
||||
$quote = $this->createQuote();
|
||||
$this->createSettlement($quote, [], company_id() + 999, '2026-07');
|
||||
|
||||
Livewire::test(WarehouseQuoteComponent::class)
|
||||
->assertDontSee('2026-07')
|
||||
->call('viewSettlementDetails', '2026-07')
|
||||
->assertSet('settlementDetailMessage', '未找到 2026-07 的核算存档。')
|
||||
->call('exportSettlement', '2026-07')
|
||||
->assertSet('settlementError', '未找到 2026-07 的核算存档,请先核算。');
|
||||
}
|
||||
|
||||
private function createQuote(): ThirdPartyWarehouseQuote
|
||||
{
|
||||
return ThirdPartyWarehouseQuote::query()->create([
|
||||
@ -137,4 +189,38 @@ class ThirdPartyWarehouseQuotePageTest extends FeatureTestCase
|
||||
'updated_by' => $this->user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $dailySku */
|
||||
private function createSettlement(
|
||||
ThirdPartyWarehouseQuote $quote,
|
||||
array $dailySku,
|
||||
?int $companyId = null,
|
||||
string $period = '2026-08',
|
||||
): void
|
||||
{
|
||||
ThirdPartyWarehouseStorageSettlement::query()->create([
|
||||
'company_id' => $companyId ?? company_id(),
|
||||
'warehouse_id' => WarehouseQuoteComponent::WAREHOUSE_ID,
|
||||
'warehouse_name' => '南昌樵舍仓',
|
||||
'period' => $period,
|
||||
'quote_id' => $quote->id,
|
||||
'inbound_rate_per_ton' => 20,
|
||||
'outbound_rate_per_order' => 0.85,
|
||||
'pieces_per_pallet' => 23,
|
||||
'pallet_area_sqm' => 3,
|
||||
'storage_rate_per_sqm_month' => 10.5,
|
||||
'days_in_month' => 31,
|
||||
'inbound_tons' => 0,
|
||||
'outbound_qty' => 0,
|
||||
'inbound_fee' => 0,
|
||||
'outbound_fee' => 0,
|
||||
'storage_fee' => array_sum(array_column($dailySku, 'storage_fee')),
|
||||
'memo_fee' => 0,
|
||||
'loss_fee' => 0,
|
||||
'compensation_fee' => 0,
|
||||
'total_fee' => array_sum(array_column($dailySku, 'storage_fee')),
|
||||
'detail_json' => ['daily' => [], 'per_sku' => [], 'daily_sku' => $dailySku],
|
||||
'calculated_by' => user_id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ namespace Tests\Feature\Business;
|
||||
|
||||
use App\Business\ThirdPartyWarehouseProductWeightRepository;
|
||||
use App\Business\ThirdPartyWarehouseStorageFeeCalculator;
|
||||
use App\Exports\ThirdPartyWarehouseStorageSettlementExport;
|
||||
use App\Models\Business\ThirdPartyWarehouseQuote;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
|
||||
@ -67,6 +68,24 @@ class ThirdPartyWarehouseStorageFeeCalculatorTest extends FeatureTestCase
|
||||
$this->assertSame(round(46.0 + 8.5 + $expectedStorage, 2), (float) $summary['total_fee']);
|
||||
$this->assertCount(2, $result['daily']);
|
||||
$this->assertCount(2, $result['per_sku']);
|
||||
$this->assertCount(3, $result['daily_sku']);
|
||||
|
||||
$firstDay = collect($result['daily_sku'])->where('date', '2026-08-01');
|
||||
$carriedStock = $firstDay->firstWhere('sku_code', 'A');
|
||||
$this->assertSame(100.0, (float) $carriedStock['opening']);
|
||||
$this->assertSame(0.0, (float) $carriedStock['net_movement']);
|
||||
$this->assertSame(100.0, (float) $carriedStock['closing']);
|
||||
$this->assertEqualsWithDelta(100 / 23 * 3 * 10.5 / 31, $carriedStock['storage_fee'], 0.000001);
|
||||
$this->assertEqualsWithDelta(
|
||||
$result['daily'][0]['storage_fee'],
|
||||
$firstDay->sum('storage_fee'),
|
||||
0.0001,
|
||||
);
|
||||
|
||||
$sheets = (new ThirdPartyWarehouseStorageSettlementExport($result))->sheets();
|
||||
$this->assertCount(4, $sheets);
|
||||
$this->assertSame('每日商品仓储费', $sheets[3]->title());
|
||||
$this->assertSame('A', $sheets[3]->array()[0][2]);
|
||||
}
|
||||
|
||||
public function test_requires_effective_quote(): void
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user