first version
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* 单号生成服务
|
||||
*
|
||||
* 规则:前缀 + yyyyMMdd + 4 位序列,如 PO202607230001;
|
||||
* 按「前缀+当日」已生成的最大序列自增。
|
||||
*
|
||||
* 并发安全提示:本服务取当日最大单号 +1,依赖各单号字段的唯一索引兜底,
|
||||
* 高并发生成场景(如采购单汇总)须在事务内配合行锁调用(见 PurchaseGenerateService)。
|
||||
*/
|
||||
class BillNumberService
|
||||
{
|
||||
/**
|
||||
* 前缀 → [表名, 单号字段] 映射
|
||||
*
|
||||
* @var array<string, array{0: string, 1: string}>
|
||||
*/
|
||||
private const NUMBER_SOURCES = [
|
||||
'PO' => ['purchase_order', 'purchase_no'],
|
||||
'SO' => ['store_order', 'order_no'],
|
||||
'RC' => ['reconciliation', 'recon_no'],
|
||||
'ST' => ['statement', 'statement_no'],
|
||||
'JS' => ['settlement', 'settlement_no'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 生成业务单号
|
||||
*
|
||||
* @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / ST 对账单 / JS 结算
|
||||
* @return string 如 PO202607230001
|
||||
*/
|
||||
public function make(string $prefix): string
|
||||
{
|
||||
$prefix = strtoupper($prefix);
|
||||
$source = self::NUMBER_SOURCES[$prefix]
|
||||
?? throw new InvalidArgumentException('不支持的单号前缀:' . $prefix);
|
||||
|
||||
[$table, $column] = $source;
|
||||
$datePrefix = $prefix . now()->format('Ymd');
|
||||
|
||||
$maxNo = DB::table($table)
|
||||
->where($column, 'like', $datePrefix . '%')
|
||||
->lockForUpdate()
|
||||
->max($column);
|
||||
|
||||
$sequence = 1;
|
||||
if (is_string($maxNo) && $maxNo !== '') {
|
||||
$sequence = ((int) substr($maxNo, strlen($datePrefix))) + 1;
|
||||
}
|
||||
|
||||
return $datePrefix . str_pad((string) $sequence, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Exports\PurchaseOrderExport;
|
||||
use App\Exports\SettlementExport;
|
||||
use App\Exports\StatementExport;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\SettlementModel;
|
||||
use App\Models\StatementModel;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 导出统一入口:按业务类型 + format 分发到 app/Exports 导出类 / PDF 模板
|
||||
*
|
||||
* - Excel 分支:Excel::download(new XxxExport(...))
|
||||
* - PDF 分支:Pdf::loadView('exports.xxx', ...)->setPaper('a4')->download(...),模板统一 font-family: SimHei
|
||||
* - 文件名规范:{单号}_{业务名}.{ext},中文文件名由响应自动做 RFC 5987 编码
|
||||
*/
|
||||
class ExportService
|
||||
{
|
||||
/** 导出格式:Excel */
|
||||
public const FORMAT_XLSX = 'xlsx';
|
||||
/** 导出格式:PDF */
|
||||
public const FORMAT_PDF = 'pdf';
|
||||
|
||||
/**
|
||||
* 导出下载
|
||||
*
|
||||
* @param string $business 业务类型:purchase 采购单 / statement 门店对账单 / settlement 结算表
|
||||
* @param mixed $subject 业务主体(如 PurchaseOrderModel / StatementModel / SettlementModel 实例)
|
||||
* @param string $format 导出格式 xlsx|pdf,默认 xlsx,非法值报错
|
||||
* @param string|null $type 业务子类型(purchase 专用:all 全品类 / category 仅蔬果分类)
|
||||
* @return Response 文件流响应(blob)
|
||||
*/
|
||||
public function download(
|
||||
string $business,
|
||||
mixed $subject,
|
||||
string $format = self::FORMAT_XLSX,
|
||||
?string $type = null,
|
||||
): Response {
|
||||
if (! in_array($format, [self::FORMAT_XLSX, self::FORMAT_PDF], true)) {
|
||||
throw new RepositoryException('导出格式参数不正确(仅支持 xlsx / pdf)');
|
||||
}
|
||||
|
||||
return match ($business) {
|
||||
'purchase' => $this->exportPurchase($subject, $format, $type),
|
||||
'statement' => $this->exportStatement($subject, $format),
|
||||
'settlement' => $this->exportSettlement($subject, $format),
|
||||
default => throw new RepositoryException('不支持的导出业务类型:' . $business),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* C2/C3 采购单导出
|
||||
*/
|
||||
private function exportPurchase(PurchaseOrderModel $purchase, string $format, ?string $type): Response
|
||||
{
|
||||
$type = in_array($type, ['all', 'category'], true) ? $type : 'all';
|
||||
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
|
||||
$filename = $purchase->purchase_no . '_采购单.' . $extension;
|
||||
|
||||
$export = new PurchaseOrderExport($purchase, $type);
|
||||
if ($format === self::FORMAT_PDF) {
|
||||
return Pdf::loadView('exports.purchase', $export->viewData())
|
||||
->setPaper('a4')
|
||||
->download($filename);
|
||||
}
|
||||
|
||||
return Excel::download($export, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店对账单导出
|
||||
*/
|
||||
private function exportStatement(StatementModel $statement, string $format): Response
|
||||
{
|
||||
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
|
||||
$filename = $statement->statement_no . '_对账单.' . $extension;
|
||||
|
||||
$export = new StatementExport($statement);
|
||||
if ($format === self::FORMAT_PDF) {
|
||||
return Pdf::loadView('exports.statement', $export->viewData())
|
||||
->setPaper('a4')
|
||||
->download($filename);
|
||||
}
|
||||
|
||||
return Excel::download($export, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* D10 结算表导出
|
||||
*/
|
||||
private function exportSettlement(SettlementModel $settlement, string $format): Response
|
||||
{
|
||||
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
|
||||
$filename = $settlement->settlement_no . '_结算表.' . $extension;
|
||||
|
||||
$export = new SettlementExport($settlement);
|
||||
if ($format === self::FORMAT_PDF) {
|
||||
return Pdf::loadView('exports.settlement', $export->viewData())
|
||||
->setPaper('a4')
|
||||
->download($filename);
|
||||
}
|
||||
|
||||
return Excel::download($export, $filename);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\PurchaseAllocationModel;
|
||||
use App\Models\PurchaseOrderItemModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* D3 采购金额分摊
|
||||
*
|
||||
* 流程(事务内):
|
||||
* 1. 采购单须已录入实际金额(存在 amount>0 的明细),否则拒绝
|
||||
* 2. 每个采购明细溯源「采购日当天、已汇总订单」中该商品的订货明细
|
||||
* 3. 按订货数量比例分摊实际金额/数量/重量:bcmul(item.amount, bcdiv(item_qty, total_qty, 6), 2)
|
||||
* 尾差修正——最后一行承担舍入差额,保证 Σallocation.amount === item.amount(金额守恒)
|
||||
* 4. 重复分摊先删旧记录再重建(幂等)
|
||||
*/
|
||||
class PurchaseAllocateService
|
||||
{
|
||||
/**
|
||||
* @return int 生成的分摊记录数
|
||||
*/
|
||||
public function allocate(PurchaseOrderModel $purchase): int
|
||||
{
|
||||
return DB::transaction(function () use ($purchase) {
|
||||
$items = PurchaseOrderItemModel::query()
|
||||
->where('purchase_id', $purchase->id)
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
// 1. 须已录入实际金额
|
||||
if (! $items->contains(static fn ($item) => (float) $item->amount > 0)) {
|
||||
throw new RepositoryException('采购单尚未录入实际金额,无法分摊');
|
||||
}
|
||||
|
||||
// 2. 溯源采购日当天「已汇总」订单的订货明细,按商品分组
|
||||
$orderItems = StoreOrderItemModel::query()
|
||||
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
||||
->whereDate('store_order.order_date', $purchase->purchase_date)
|
||||
->where('store_order.status', StoreOrderModel::STATUS_SUMMARIZED)
|
||||
->select('store_order_item.*')
|
||||
->get()
|
||||
->groupBy('product_id');
|
||||
|
||||
// 4. 幂等:删除旧分摊记录
|
||||
PurchaseAllocationModel::query()
|
||||
->whereIn('purchase_item_id', $items->pluck('id'))
|
||||
->delete();
|
||||
|
||||
$count = 0;
|
||||
$now = now();
|
||||
foreach ($items as $item) {
|
||||
$sources = $orderItems->get((int) $item->product_id);
|
||||
if ($sources === null || $sources->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalQty = $sources->reduce(
|
||||
static fn (string $carry, $orderItem): string => bcadd($carry, (string) $orderItem->quantity, 2),
|
||||
'0'
|
||||
);
|
||||
if ((float) $totalQty <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourceValues = $sources->values();
|
||||
$lastIndex = $sourceValues->count() - 1;
|
||||
$allocatedAmount = '0';
|
||||
$allocatedQuantity = '0';
|
||||
$allocatedWeight = '0';
|
||||
$records = [];
|
||||
|
||||
foreach ($sourceValues as $index => $orderItem) {
|
||||
if ($index === $lastIndex) {
|
||||
// 3. 尾差修正:最后一行 = 总额 − 已分摊,保证守恒
|
||||
$amount = bcsub((string) $item->amount, $allocatedAmount, 2);
|
||||
$quantity = bcsub((string) $item->quantity, $allocatedQuantity, 2);
|
||||
$weight = bcsub((string) $item->weight, $allocatedWeight, 3);
|
||||
} else {
|
||||
$ratio = bcdiv((string) $orderItem->quantity, $totalQty, 6);
|
||||
$amount = bcmul((string) $item->amount, $ratio, 2);
|
||||
$quantity = bcmul((string) $item->quantity, $ratio, 2);
|
||||
$weight = bcmul((string) $item->weight, $ratio, 3);
|
||||
$allocatedAmount = bcadd($allocatedAmount, $amount, 2);
|
||||
$allocatedQuantity = bcadd($allocatedQuantity, $quantity, 2);
|
||||
$allocatedWeight = bcadd($allocatedWeight, $weight, 3);
|
||||
}
|
||||
|
||||
$records[] = [
|
||||
'purchase_item_id' => $item->id,
|
||||
'order_item_id' => $orderItem->id,
|
||||
'store_id' => $orderItem->store_id,
|
||||
'product_id' => $item->product_id,
|
||||
'quantity' => $quantity,
|
||||
'weight' => $weight,
|
||||
'amount' => $amount,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
PurchaseAllocationModel::insert($records);
|
||||
$count += count($records);
|
||||
}
|
||||
|
||||
return $count;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseOrderItemModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* C1 订单汇总生成采购单
|
||||
*
|
||||
* 流程(事务内):
|
||||
* 1. 行锁查询当日全部「待汇总」订单(无则报错;状态条件天然排除已汇总订单,幂等)
|
||||
* 2. 展开明细按商品聚合(Σquantity,快照品名/规格;供应商取商品默认供应商)
|
||||
* 3. 估算单价 = 该商品最低等级价(product_price MIN),amount = quantity × 估算单价
|
||||
* 4. 创建采购单头(PO 单号,estimate_amount = Σitems.amount)
|
||||
* 5. 明细按「分类 sort → 商品 sort」排序写入 sort 行号
|
||||
* 6. 源订单批量回写 status = 已汇总
|
||||
*/
|
||||
class PurchaseGenerateService
|
||||
{
|
||||
public function __construct(private readonly BillNumberService $billNumberService)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $date 订货/采购日期(Y-m-d)
|
||||
* @param int $operatorId 制单人(后台系统用户ID)
|
||||
*/
|
||||
public function generate(string $date, int $operatorId): PurchaseOrderModel
|
||||
{
|
||||
return DB::transaction(function () use ($date, $operatorId) {
|
||||
// 1. 行锁当日待汇总订单(并发防护)
|
||||
$orders = StoreOrderModel::query()
|
||||
->whereDate('order_date', $date)
|
||||
->where('status', StoreOrderModel::STATUS_PENDING)
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
if ($orders->isEmpty()) {
|
||||
throw new RepositoryException('当日无待汇总订单');
|
||||
}
|
||||
|
||||
// 2. 展开明细按商品聚合
|
||||
$aggregated = [];
|
||||
$orderIds = [];
|
||||
foreach ($orders as $order) {
|
||||
$orderIds[] = $order->id;
|
||||
foreach ($order->items as $item) {
|
||||
$productId = (int) $item->product_id;
|
||||
if (! isset($aggregated[$productId])) {
|
||||
$aggregated[$productId] = [
|
||||
'product_id' => $productId,
|
||||
'product_name' => $item->product_name,
|
||||
'product_spec' => $item->product_spec,
|
||||
'quantity' => '0',
|
||||
];
|
||||
}
|
||||
$aggregated[$productId]['quantity'] = bcadd(
|
||||
$aggregated[$productId]['quantity'],
|
||||
(string) $item->quantity,
|
||||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($aggregated === []) {
|
||||
throw new RepositoryException('当日待汇总订单均无明细,无法生成采购单');
|
||||
}
|
||||
|
||||
$products = ProductModel::withTrashed()
|
||||
->with('category:id,sort')
|
||||
->whereIn('id', array_keys($aggregated))
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// 3. 估算单价 = 最低等级价
|
||||
$minPrices = ProductPriceModel::query()
|
||||
->whereIn('product_id', array_keys($aggregated))
|
||||
->groupBy('product_id')
|
||||
->selectRaw('product_id, MIN(price) as min_price')
|
||||
->pluck('min_price', 'product_id');
|
||||
|
||||
// 组装明细行并按「分类 sort → 商品 sort」排序
|
||||
$rows = [];
|
||||
foreach ($aggregated as $productId => $item) {
|
||||
$product = $products->get($productId);
|
||||
$price = (string) ($minPrices[$productId] ?? '0');
|
||||
$rows[] = [
|
||||
'product_id' => $productId,
|
||||
'supplier_id' => (int) ($product->supplier_id ?? 0),
|
||||
'product_name' => $item['product_name'],
|
||||
'product_spec' => $item['product_spec'],
|
||||
'price' => $price,
|
||||
'quantity' => $item['quantity'],
|
||||
'amount' => bcmul($item['quantity'], $price, 2),
|
||||
'category_sort' => (int) ($product->category->sort ?? 9999),
|
||||
'product_sort' => (int) ($product->sort ?? 9999),
|
||||
];
|
||||
}
|
||||
usort($rows, static fn (array $a, array $b): int =>
|
||||
[$a['category_sort'], $a['product_sort'], $a['product_id']]
|
||||
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
|
||||
|
||||
$estimateAmount = array_reduce(
|
||||
$rows,
|
||||
static fn (string $carry, array $row): string => bcadd($carry, $row['amount'], 2),
|
||||
'0'
|
||||
);
|
||||
$totalQuantity = array_reduce(
|
||||
$rows,
|
||||
static fn (string $carry, array $row): string => bcadd($carry, $row['quantity'], 2),
|
||||
'0'
|
||||
);
|
||||
|
||||
// 4. 采购单头
|
||||
$purchase = PurchaseOrderModel::create([
|
||||
'purchase_no' => $this->billNumberService->make('PO'),
|
||||
'purchase_date' => $date,
|
||||
'status' => PurchaseOrderModel::STATUS_PENDING,
|
||||
'total_quantity' => $totalQuantity,
|
||||
'total_weight' => 0,
|
||||
'estimate_amount' => $estimateAmount,
|
||||
'actual_amount' => 0,
|
||||
'operator_id' => $operatorId,
|
||||
]);
|
||||
|
||||
// 5. 采购明细(sort 行号)
|
||||
$sort = 1;
|
||||
foreach ($rows as $row) {
|
||||
PurchaseOrderItemModel::create([
|
||||
'purchase_id' => $purchase->id,
|
||||
'product_id' => $row['product_id'],
|
||||
'supplier_id' => $row['supplier_id'],
|
||||
'product_name' => $row['product_name'],
|
||||
'product_spec' => $row['product_spec'],
|
||||
'price' => $row['price'],
|
||||
'quantity' => $row['quantity'],
|
||||
'weight' => 0,
|
||||
'amount' => $row['amount'],
|
||||
'sort' => $sort++,
|
||||
'is_sent' => PurchaseOrderItemModel::NOT_SENT,
|
||||
]);
|
||||
}
|
||||
|
||||
// 6. 源订单回写「已汇总」
|
||||
StoreOrderModel::whereIn('id', $orderIds)
|
||||
->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
|
||||
return $purchase;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\PurchaseAllocationModel;
|
||||
use App\Models\PurchaseOrderItemModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\ReconciliationItemModel;
|
||||
use App\Models\ReconciliationModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 对账明细构建(D1 品类 / D2 供应商筛选)
|
||||
*
|
||||
* 流程(事务内,可重复 build:先清后建):
|
||||
* 1. 按周期 + 品类(含子孙分类)+ 供应商拉取 purchase_order_item
|
||||
* 2. 校验命中的采购明细均已完成 D3 分摊(分摊是门店/单品粒度的对账数据源)
|
||||
* 3. 每条分摊记录 → 一条对账明细:
|
||||
* publish_amount = 订货金额(溯源 order_item.amount)
|
||||
* actual_amount = 分摊金额
|
||||
* diff = publish − actual,冗余 product_name / store_id
|
||||
* 4. 汇总写回头的 publish/actual/diff_amount,status → 对账中
|
||||
*/
|
||||
class ReconciliationBuildService
|
||||
{
|
||||
/**
|
||||
* @return int 生成的对账明细数
|
||||
*/
|
||||
public function build(ReconciliationModel $recon): int
|
||||
{
|
||||
return DB::transaction(function () use ($recon) {
|
||||
// 1. 按周期 + 品类 + 供应商拉取采购明细
|
||||
$itemQuery = PurchaseOrderItemModel::query()
|
||||
->join('purchase_order', 'purchase_order.id', '=', 'purchase_order_item.purchase_id')
|
||||
->whereDate('purchase_order.purchase_date', '>=', $recon->period_start)
|
||||
->whereDate('purchase_order.purchase_date', '<=', $recon->period_end)
|
||||
->select('purchase_order_item.*');
|
||||
|
||||
if ((int) $recon->supplier_id > 0) {
|
||||
$itemQuery->where('purchase_order_item.supplier_id', $recon->supplier_id);
|
||||
}
|
||||
if ((int) $recon->category_id > 0) {
|
||||
$productIds = ProductModel::withTrashed()
|
||||
->whereIn('category_id', $this->descendantCategoryIds((int) $recon->category_id))
|
||||
->pluck('id');
|
||||
$itemQuery->whereIn('purchase_order_item.product_id', $productIds);
|
||||
}
|
||||
|
||||
$purchaseItems = $itemQuery->get();
|
||||
if ($purchaseItems->isEmpty()) {
|
||||
throw new RepositoryException('周期内无符合筛选条件的采购数据,无法生成对账明细');
|
||||
}
|
||||
|
||||
// 2. 分摊记录(未完成分摊的采购单拒绝,保证对账数据到门店/单品粒度)
|
||||
$allocations = PurchaseAllocationModel::query()
|
||||
->whereIn('purchase_item_id', $purchaseItems->pluck('id'))
|
||||
->get()
|
||||
->groupBy('purchase_item_id');
|
||||
|
||||
$missing = $purchaseItems->filter(fn ($item) => ! $allocations->has($item->id));
|
||||
if ($missing->isNotEmpty()) {
|
||||
$purchaseNos = PurchaseOrderModel::query()
|
||||
->whereIn('id', $missing->pluck('purchase_id')->unique())
|
||||
->pluck('purchase_no')
|
||||
->implode('、');
|
||||
throw new RepositoryException('采购单 ' . $purchaseNos . ' 尚未完成金额分摊,请先执行分摊再生成对账明细');
|
||||
}
|
||||
|
||||
// 3. 溯源订货金额(公布金额)
|
||||
$orderAmounts = StoreOrderItemModel::query()
|
||||
->whereIn('id', $allocations->flatten()->pluck('order_item_id')->unique())
|
||||
->pluck('amount', 'id');
|
||||
|
||||
// 4. 先清后建(幂等)
|
||||
ReconciliationItemModel::where('recon_id', $recon->id)->delete();
|
||||
|
||||
$publishTotal = '0';
|
||||
$actualTotal = '0';
|
||||
$rows = [];
|
||||
$sort = 1;
|
||||
$now = now();
|
||||
foreach ($allocations as $purchaseItemId => $group) {
|
||||
$purchaseItem = $purchaseItems->firstWhere('id', $purchaseItemId);
|
||||
foreach ($group as $allocation) {
|
||||
$publish = (string) ($orderAmounts[$allocation->order_item_id] ?? '0');
|
||||
$actual = (string) $allocation->amount;
|
||||
$publishTotal = bcadd($publishTotal, $publish, 2);
|
||||
$actualTotal = bcadd($actualTotal, $actual, 2);
|
||||
|
||||
$rows[] = [
|
||||
'recon_id' => $recon->id,
|
||||
'store_id' => $allocation->store_id,
|
||||
'purchase_item_id' => $allocation->purchase_item_id,
|
||||
'order_item_id' => $allocation->order_item_id,
|
||||
'product_id' => $allocation->product_id,
|
||||
'product_name' => $purchaseItem->product_name,
|
||||
'quantity' => $allocation->quantity,
|
||||
'weight' => $allocation->weight,
|
||||
'publish_amount' => $publish,
|
||||
'actual_amount' => $actual,
|
||||
'diff_amount' => bcsub($publish, $actual, 2),
|
||||
'is_reconciled' => ReconciliationItemModel::NOT_RECONCILED,
|
||||
'store_remark' => '',
|
||||
'sort' => $sort++,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
}
|
||||
ReconciliationItemModel::insert($rows);
|
||||
|
||||
// 5. 汇总写回头 + 状态流转
|
||||
$recon->publish_amount = $publishTotal;
|
||||
$recon->actual_amount = $actualTotal;
|
||||
$recon->diff_amount = bcsub($publishTotal, $actualTotal, 2);
|
||||
$recon->status = ReconciliationModel::STATUS_WORKING;
|
||||
$recon->save();
|
||||
|
||||
return count($rows);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类自身 + 全部子孙分类ID(多级分类下按顶级分类筛选)
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function descendantCategoryIds(int $categoryId): array
|
||||
{
|
||||
$parentMap = ProductCategoryModel::pluck('parent_id', 'id');
|
||||
$ids = [$categoryId];
|
||||
$queue = [$categoryId];
|
||||
while ($queue !== []) {
|
||||
$current = array_shift($queue);
|
||||
foreach ($parentMap as $id => $parentId) {
|
||||
if ((int) $parentId === $current && ! in_array((int) $id, $ids, true)) {
|
||||
$ids[] = (int) $id;
|
||||
$queue[] = (int) $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\StatementItemModel;
|
||||
use App\Models\StatementModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 门店对账单生成(小程序端自助生成)
|
||||
*
|
||||
* 流程(事务内):
|
||||
* 1. 拉取门店周期内的订单明细(排除已取消订单,按 order_item 去重防止重复入账)
|
||||
* 2. 快照当前 payment_cycle_days,settlement_date = period_end + cycle 天
|
||||
* 3. 明细快照商品名/单价/数量/重量/金额,statement_no = ST…
|
||||
*/
|
||||
class StatementGenerateService
|
||||
{
|
||||
public function __construct(private readonly BillNumberService $billNumberService)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param StoreModel $store 门店(回款周期从此快照)
|
||||
* @param string $periodStart 周期开始(Y-m-d)
|
||||
* @param string $periodEnd 周期结束(Y-m-d)
|
||||
*/
|
||||
public function generate(StoreModel $store, string $periodStart, string $periodEnd): StatementModel
|
||||
{
|
||||
return DB::transaction(function () use ($store, $periodStart, $periodEnd) {
|
||||
$orderItems = StoreOrderItemModel::query()
|
||||
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
||||
->where('store_order_item.store_id', $store->id)
|
||||
->whereDate('store_order.order_date', '>=', $periodStart)
|
||||
->whereDate('store_order.order_date', '<=', $periodEnd)
|
||||
->where('store_order.status', '<>', StoreOrderModel::STATUS_CANCELLED)
|
||||
->select('store_order_item.*')
|
||||
->get();
|
||||
|
||||
if ($orderItems->isEmpty()) {
|
||||
throw new RepositoryException('周期内本店无订单数据,无法生成对账单');
|
||||
}
|
||||
|
||||
// 防重复入账:剔除已计入过对账单的订单明细
|
||||
$usedItemIds = StatementItemModel::query()
|
||||
->whereIn('statement_id', StatementModel::where('store_id', $store->id)->pluck('id'))
|
||||
->pluck('order_item_id');
|
||||
$orderItems = $orderItems->reject(fn ($item) => $usedItemIds->contains($item->id));
|
||||
if ($orderItems->isEmpty()) {
|
||||
throw new RepositoryException('周期内的订单明细均已生成过对账单');
|
||||
}
|
||||
|
||||
// 快照回款周期 → 应结算日期
|
||||
$cycleDays = (int) $store->payment_cycle_days;
|
||||
$totalAmount = $orderItems->reduce(
|
||||
static fn (string $carry, $item): string => bcadd($carry, (string) $item->amount, 2),
|
||||
'0'
|
||||
);
|
||||
|
||||
$statement = StatementModel::create([
|
||||
'statement_no' => $this->billNumberService->make('ST'),
|
||||
'store_id' => $store->id,
|
||||
'period_start' => $periodStart,
|
||||
'period_end' => $periodEnd,
|
||||
'total_amount' => $totalAmount,
|
||||
'payment_cycle_days' => $cycleDays,
|
||||
'settlement_date' => Carbon::parse($periodEnd)->addDays($cycleDays)->toDateString(),
|
||||
'status' => StatementModel::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
$rows = [];
|
||||
$now = now();
|
||||
foreach ($orderItems as $item) {
|
||||
$rows[] = [
|
||||
'statement_id' => $statement->id,
|
||||
'order_id' => $item->order_id,
|
||||
'order_item_id' => $item->id,
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $item->product_name,
|
||||
'price' => $item->price,
|
||||
'quantity' => $item->quantity,
|
||||
'weight' => $item->weight,
|
||||
'amount' => $item->amount,
|
||||
'is_reconciled' => StatementItemModel::NOT_RECONCILED,
|
||||
'store_remark' => '',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
StatementItemModel::insert($rows);
|
||||
|
||||
return $statement;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use EasyWeChat\Kernel\Exceptions\HttpException;
|
||||
use EasyWeChat\MiniApp\Application;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* 微信小程序服务(基于 EasyWeChat 6.x)
|
||||
*
|
||||
* 封装 code2Session / 手机号解密;配置读取 config('services.wechat.mini')
|
||||
* (env:WECHAT_MINI_APPID / WECHAT_MINI_SECRET,需业务方提供)。
|
||||
*
|
||||
* 测试策略:通过 setHttpClient() 注入 Symfony MockHttpClient 拦截微信 HTTP 调用。
|
||||
*/
|
||||
class WechatService
|
||||
{
|
||||
private ?Application $app = null;
|
||||
|
||||
private ?HttpClientInterface $httpClient = null;
|
||||
|
||||
/**
|
||||
* 注入自定义 HttpClient(测试注入 MockHttpClient;注入后强制重建 Application)
|
||||
*/
|
||||
public function setHttpClient(HttpClientInterface $httpClient): void
|
||||
{
|
||||
$this->httpClient = $httpClient;
|
||||
$this->app = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* code2Session:小程序 wx.login 的 code 换取 openid / session_key
|
||||
*
|
||||
* @param string $code wx.login 返回的临时登录凭证
|
||||
* @return array{openid: string, session_key: string, unionid?: string}
|
||||
*/
|
||||
public function code2Session(string $code): array
|
||||
{
|
||||
try {
|
||||
/** @var array{openid: string, session_key: string, unionid?: string} $session */
|
||||
$session = $this->app()->getUtils()->codeToSession($code);
|
||||
} catch (HttpException|TransportExceptionInterface $e) {
|
||||
throw new RepositoryException('微信登录失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取手机号:wx.getPhoneNumber 的 phoneCode 换取手机号
|
||||
*
|
||||
* @param string $phoneCode 手机号授权事件返回的动态令牌
|
||||
* @return string 用户手机号
|
||||
*/
|
||||
public function getPhone(string $phoneCode): string
|
||||
{
|
||||
try {
|
||||
$result = $this->app()->getUtils()->getPhoneNumber($phoneCode);
|
||||
} catch (HttpException|TransportExceptionInterface $e) {
|
||||
throw new RepositoryException('获取手机号失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
$phone = (string) ($result['phone_info']['phoneNumber']
|
||||
?? $result['phone_info']['purePhoneNumber']
|
||||
?? '');
|
||||
if ($phone === '') {
|
||||
throw new RepositoryException('获取手机号失败:微信未返回有效手机号');
|
||||
}
|
||||
|
||||
return $phone;
|
||||
}
|
||||
|
||||
/**
|
||||
* EasyWeChat 小程序应用实例(懒构建单例)
|
||||
*/
|
||||
protected function app(): Application
|
||||
{
|
||||
if ($this->app === null) {
|
||||
$config = (array) config('services.wechat.mini', []);
|
||||
if (empty($config['appid']) || empty($config['secret'])) {
|
||||
throw new RepositoryException('微信小程序尚未配置(WECHAT_MINI_APPID / WECHAT_MINI_SECRET)');
|
||||
}
|
||||
|
||||
$this->app = new Application([
|
||||
'app_id' => (string) $config['appid'],
|
||||
'secret' => (string) $config['secret'],
|
||||
]);
|
||||
|
||||
if ($this->httpClient !== null) {
|
||||
$this->app->setHttpClient($this->httpClient);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->app;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user