Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbb92e00d5 | |||
| e26206b93c |
@@ -220,6 +220,10 @@ body.admin-col-resizing * {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.admin-primary-link {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-primary-link:hover {
|
.admin-primary-link:hover {
|
||||||
color: #1677ff;
|
color: #1677ff;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Form,
|
Form,
|
||||||
@@ -46,6 +47,7 @@ const STATUS_OPTIONS = (Object.keys(DEV_PLAN_TASK_STATUS_LABELS) as DevPlanTaskS
|
|||||||
|
|
||||||
const STATUS_COLOR: Record<DevPlanTaskStatusDto, string> = {
|
const STATUS_COLOR: Record<DevPlanTaskStatusDto, string> = {
|
||||||
TODO: 'default',
|
TODO: 'default',
|
||||||
|
IN_PROGRESS: 'orange',
|
||||||
DEVELOPED: 'blue',
|
DEVELOPED: 'blue',
|
||||||
RELEASED: 'green',
|
RELEASED: 'green',
|
||||||
};
|
};
|
||||||
@@ -289,7 +291,19 @@ export default function DevPlanTasksPage() {
|
|||||||
'—'
|
'—'
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
|
{
|
||||||
|
title: '来源工单',
|
||||||
|
dataIndex: 'supportTicketNo',
|
||||||
|
width: 140,
|
||||||
|
render: (v, row) =>
|
||||||
|
row.supportTicketId && v ? (
|
||||||
|
<Link className="admin-primary-link" to={`/tickets/support?id=${row.supportTicketId}`}>
|
||||||
|
{v}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
v || '—'
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '关联版本',
|
title: '关联版本',
|
||||||
dataIndex: 'versions',
|
dataIndex: 'versions',
|
||||||
@@ -377,7 +391,7 @@ export default function DevPlanTasksPage() {
|
|||||||
<Select allowClear style={{ width: 100 }} options={TYPE_OPTIONS} />
|
<Select allowClear style={{ width: 100 }} options={TYPE_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select allowClear style={{ width: 100 }} options={STATUS_OPTIONS} />
|
<Select allowClear style={{ width: 110 }} options={STATUS_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="keyword" label="关键词">
|
<Form.Item name="keyword" label="关键词">
|
||||||
<Input allowClear placeholder="任务号/内容" style={{ width: 160 }} />
|
<Input allowClear placeholder="任务号/内容" style={{ width: 160 }} />
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ export default function PartnerBillsPage() {
|
|||||||
title: '账期',
|
title: '账期',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (_, r) =>
|
render: (_, r) =>
|
||||||
`${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}`,
|
`${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '酒单佣金',
|
title: '酒单佣金',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
|
DEV_PLAN_TASK_STATUS_LABELS,
|
||||||
DEV_PLAN_TASK_TYPE_LABELS,
|
DEV_PLAN_TASK_TYPE_LABELS,
|
||||||
SUPPORT_TICKET_STATUS_LABELS,
|
SUPPORT_TICKET_STATUS_LABELS,
|
||||||
SUPPORT_TICKET_TYPE_LABELS,
|
SUPPORT_TICKET_TYPE_LABELS,
|
||||||
@@ -89,6 +91,8 @@ const TASK_TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTask
|
|||||||
type TicketRow = SupportTicketDto & { linkedTasks?: SupportTicketLinkedTaskDto[] };
|
type TicketRow = SupportTicketDto & { linkedTasks?: SupportTicketLinkedTaskDto[] };
|
||||||
|
|
||||||
export default function SupportTicketsPage() {
|
export default function SupportTicketsPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const deepLinkId = searchParams.get('id')?.trim() || searchParams.get('ticketId')?.trim() || '';
|
||||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||||
@@ -162,6 +166,11 @@ export default function SupportTicketsPage() {
|
|||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!deepLinkId) return;
|
||||||
|
void openDetail(deepLinkId).catch(() => message.error('工单不存在或已删除'));
|
||||||
|
}, [deepLinkId]);
|
||||||
|
|
||||||
async function submitCreate() {
|
async function submitCreate() {
|
||||||
const values = await createForm.validateFields();
|
const values = await createForm.validateFields();
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
@@ -914,7 +923,9 @@ export default function SupportTicketsPage() {
|
|||||||
>
|
>
|
||||||
{t.content}
|
{t.content}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Tag style={{ margin: 0, flexShrink: 0 }}>{t.status}</Tag>
|
<Tag style={{ margin: 0, flexShrink: 0 }}>
|
||||||
|
{DEV_PLAN_TASK_STATUS_LABELS[t.status] ?? t.status}
|
||||||
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Statistic,
|
Statistic,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -249,7 +250,11 @@ export default function WineryBillsPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '账单日',
|
title: (
|
||||||
|
<Tooltip title="出账当天的北京日历日;T+3 只决定纳入哪天完成的订单">
|
||||||
|
账单日
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
dataIndex: 'billDate',
|
dataIndex: 'billDate',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (v) => String(v || '').slice(0, 10),
|
render: (v) => String(v || '').slice(0, 10),
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ function formatBenefitCorner(p: Product): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [showSplash, setShowSplash] = useState(() => !hasHomeSplashPlayed());
|
const [showSplash, setShowSplash] = useState(() => false); // !hasHomeSplashPlayed()
|
||||||
const [activeAroma, setActiveAroma] = useState<AromaKey>('QINGXIANG');
|
const [activeAroma, setActiveAroma] = useState<AromaKey>('QINGXIANG');
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|||||||
@@ -35,8 +35,15 @@ import {
|
|||||||
toWeappShareTimeline,
|
toWeappShareTimeline,
|
||||||
} from '../../lib/wechat-share';
|
} from '../../lib/wechat-share';
|
||||||
import iconHome from '../../assets/tabbar/home.png';
|
import iconHome from '../../assets/tabbar/home.png';
|
||||||
|
import iconStoreBenefit from '../../assets/icons/store-benefit-y.png';
|
||||||
import { usePageView } from '../../lib/usePageView';
|
import { usePageView } from '../../lib/usePageView';
|
||||||
|
|
||||||
|
/** 封面斜角权益额:SKU benefitAmount ?? 商品 benefitDisplay ?? 售价 */
|
||||||
|
function formatBenefitCorner(amount: number): string {
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) return '';
|
||||||
|
return String(Math.round(amount));
|
||||||
|
}
|
||||||
|
|
||||||
type Product = ProductImageSource & {
|
type Product = ProductImageSource & {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -157,6 +164,14 @@ export default function ProductDetailPage() {
|
|||||||
}, [product, specEnabled, skus, selected, attrs]);
|
}, [product, specEnabled, skus, selected, attrs]);
|
||||||
|
|
||||||
const displayPrice = activeSku ? Number(activeSku.price) : Number(product?.price ?? 0);
|
const displayPrice = activeSku ? Number(activeSku.price) : Number(product?.price ?? 0);
|
||||||
|
const benefitCorner = formatBenefitCorner(
|
||||||
|
Number(
|
||||||
|
activeSku?.benefitAmount ??
|
||||||
|
product?.benefitDisplay ??
|
||||||
|
product?.benefitAmount ??
|
||||||
|
displayPrice,
|
||||||
|
),
|
||||||
|
);
|
||||||
const fulfillment = activeSku
|
const fulfillment = activeSku
|
||||||
? {
|
? {
|
||||||
allowOnlinePurchase: activeSku.allowOnlinePurchase,
|
allowOnlinePurchase: activeSku.allowOnlinePurchase,
|
||||||
@@ -271,6 +286,20 @@ export default function ProductDetailPage() {
|
|||||||
<View className="product-detail-main">
|
<View className="product-detail-main">
|
||||||
<View className="product-detail-hero full-bleed">
|
<View className="product-detail-hero full-bleed">
|
||||||
<ProductCarousel images={carouselImages} alt={product.name} variant="detail" previewable />
|
<ProductCarousel images={carouselImages} alt={product.name} variant="detail" previewable />
|
||||||
|
{benefitCorner ? (
|
||||||
|
<View className="product-detail-benefit-ribbon-clip">
|
||||||
|
<View className="product-detail-benefit-ribbon">
|
||||||
|
<View className="product-detail-benefit-ribbon-dk">
|
||||||
|
<Image
|
||||||
|
className="product-detail-benefit-ribbon-dk-icon"
|
||||||
|
src={iconStoreBenefit}
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text className="product-detail-benefit-ribbon-num">{benefitCorner}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="product-detail-info">
|
<View className="product-detail-info">
|
||||||
@@ -288,6 +317,21 @@ export default function ProductDetailPage() {
|
|||||||
<Text className="product-detail-subtitle">{activeSku.specText}</Text>
|
<Text className="product-detail-subtitle">{activeSku.specText}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{allowOnline || allowOnSite ? (
|
||||||
|
<View className="product-detail-fulfill-tags">
|
||||||
|
{allowOnline ? (
|
||||||
|
<Text className="product-detail-fulfill-tag product-detail-fulfill-tag--local">
|
||||||
|
同城订单24小时内送达
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{allowOnSite ? (
|
||||||
|
<Text className="product-detail-fulfill-tag product-detail-fulfill-tag--pickup">
|
||||||
|
支持现场取货
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{specEnabled ? (
|
{specEnabled ? (
|
||||||
<View className="product-detail-specs">
|
<View className="product-detail-specs">
|
||||||
{attrs.map((attr) => (
|
{attrs.map((attr) => (
|
||||||
|
|||||||
@@ -8,9 +8,64 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.product-detail-hero {
|
.product-detail-hero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
background: var(--color-card);
|
background: var(--color-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-detail-benefit-ribbon-clip {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 192px;
|
||||||
|
height: 192px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-benefit-ribbon {
|
||||||
|
position: absolute;
|
||||||
|
top: 40px;
|
||||||
|
left: -52px;
|
||||||
|
width: 256px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #8b1a20;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-benefit-ribbon-dk {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 1px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-benefit-ribbon-dk-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-benefit-ribbon-num {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #dcb46f;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 48px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-carousel-wrap {
|
.detail-carousel-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -129,7 +184,35 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
color: var(--color-on-surface-variant);
|
color: var(--color-on-surface-variant);
|
||||||
margin-bottom: 24px;
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-fulfill-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 8px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-fulfill-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-fulfill-tag--local {
|
||||||
|
background: #f5d56a;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-fulfill-tag--pickup {
|
||||||
|
background: #f8e4e4;
|
||||||
|
color: #a61d24;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-detail-promo {
|
.product-detail-promo {
|
||||||
|
|||||||
+2
-1
@@ -116,6 +116,7 @@
|
|||||||
- 确认打款时可填写打款凭证号(`paymentRef`),并可上传凭证照片(`paymentProofUrls`,最多 9 张);已打款后在详情与导出中展示
|
- 确认打款时可填写打款凭证号(`paymentRef`),并可上传凭证照片(`paymentProofUrls`,最多 9 张);已打款后在详情与导出中展示
|
||||||
- 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型)
|
- 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型)
|
||||||
- 门店 T+1「出账日」= 出账当天(核销窗口「昨日 00:00–今日 00:00」中的今天)
|
- 门店 T+1「出账日」= 出账当天(核销窗口「昨日 00:00–今日 00:00」中的今天)
|
||||||
|
- **账单日一律为出账当天的北京日历日**(Asia/Shanghai):门店 T+1、酒厂 T+3 日账单的 `billDate`;合伙人/物流月账的账期按北京自然月。酒厂 T+3 只决定纳入哪天完成的订单,账单日不是完成日。
|
||||||
- 核销详情展示核销门店主账户收款信息
|
- 核销详情展示核销门店主账户收款信息
|
||||||
|
|
||||||
### 3.4~3.8 Wave 能力
|
### 3.4~3.8 Wave 能力
|
||||||
@@ -131,7 +132,7 @@
|
|||||||
| § | 主题 | 文档 |
|
| § | 主题 | 文档 |
|
||||||
|---|------|------|
|
|---|------|------|
|
||||||
| 3.9 | 门店套餐 ≤10 条、独立审核 | v3.4.10 |
|
| 3.9 | 门店套餐 ≤10 条、独立审核 | v3.4.10 |
|
||||||
| 3.10 | 开发计划/任务/版本/技术支持联动;任务导出 md/docx/xlsx/pdf;任务附件 | v3.4.11 / v3.5.6 |
|
| 3.10 | 开发计划/任务/版本/技术支持联动;任务状态含开发中;来源工单可点进技术支持;任务导出 md/docx/xlsx/pdf;任务附件 | v3.4.11 / v3.5.6 |
|
||||||
| 3.11 | 企微智能机器人 + 消息推送 Webhook | v3.4.11 |
|
| 3.11 | 企微智能机器人 + 消息推送 Webhook | v3.4.11 |
|
||||||
| 3.12 | **登录手机号 ≠ 对外联系电话**;体验优化 | v3.4.16 |
|
| 3.12 | **登录手机号 ≠ 对外联系电话**;体验优化 | v3.4.16 |
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
|||||||
|
|
||||||
**主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30…
|
**主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30…
|
||||||
**后台 8 项**:商品/门店/订单/权益/核销/工单/日志/财务。
|
**后台 8 项**:商品/门店/订单/权益/核销/工单/日志/财务。
|
||||||
|
**开发计划任务**:状态 `TODO` 待开发 / `IN_PROGRESS` 开发中 / `DEVELOPED` 已开发 / `RELEASED` 已上线;任务列表「来源工单」链到 `/tickets/support?id=`。
|
||||||
|
**财务账单日**:出账当天北京日历日(门店/酒厂 `billDate`;合伙人/物流账期按北京自然月)。禁止 `toISOString().slice(0,10)` 或服务器本地 `Date` 午夜当账单日。
|
||||||
|
|
||||||
## 6. 技术债(摘要)
|
## 6. 技术债(摘要)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type DevPlanTaskStatus = 'TODO' | 'DEVELOPED' | 'RELEASED';
|
export type DevPlanTaskStatus = 'TODO' | 'IN_PROGRESS' | 'DEVELOPED' | 'RELEASED';
|
||||||
export type DevPlanVersionStatus = 'PENDING' | 'IN_PROGRESS' | 'TESTING' | 'RELEASED';
|
export type DevPlanVersionStatus = 'PENDING' | 'IN_PROGRESS' | 'TESTING' | 'RELEASED';
|
||||||
|
|
||||||
export function computeDurationMinutes(
|
export function computeDurationMinutes(
|
||||||
|
|||||||
@@ -409,3 +409,4 @@ export * from './city-partner';
|
|||||||
export * from './dev-plan';
|
export * from './dev-plan';
|
||||||
export * from './support-ticket';
|
export * from './support-ticket';
|
||||||
export * from './phone';
|
export * from './phone';
|
||||||
|
export * from './shanghai-date';
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
addShanghaiDays,
|
||||||
|
previousShanghaiMonth,
|
||||||
|
shanghaiLaggedIssueWindow,
|
||||||
|
shanghaiMonthLastInstant,
|
||||||
|
shanghaiMonthRange,
|
||||||
|
shanghaiPeriodYmds,
|
||||||
|
shanghaiT1DayWindow,
|
||||||
|
shanghaiYmd,
|
||||||
|
startOfShanghaiDay,
|
||||||
|
} from './shanghai-date';
|
||||||
|
|
||||||
|
describe('shanghaiYmd / startOfShanghaiDay', () => {
|
||||||
|
it('UTC 16:00 已是北京次日 00:00,账单日取北京日历日', () => {
|
||||||
|
const utc = new Date('2026-08-30T16:00:00.000Z');
|
||||||
|
expect(shanghaiYmd(utc)).toBe('2026-08-31');
|
||||||
|
expect(startOfShanghaiDay(utc).toISOString()).toBe('2026-08-30T16:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UTC 15:59 仍是北京当日', () => {
|
||||||
|
const utc = new Date('2026-08-30T15:59:59.000Z');
|
||||||
|
expect(shanghaiYmd(utc)).toBe('2026-08-30');
|
||||||
|
expect(startOfShanghaiDay(utc).toISOString()).toBe('2026-08-29T16:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不依赖服务器本地时区:输入 ISO 即得到北京自然日 00:00', () => {
|
||||||
|
const noonUtc = new Date('2026-08-30T04:00:00.000Z'); // 北京 12:00
|
||||||
|
expect(shanghaiYmd(noonUtc)).toBe('2026-08-30');
|
||||||
|
expect(startOfShanghaiDay(noonUtc).toISOString()).toBe('2026-08-29T16:00:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shanghaiT1DayWindow', () => {
|
||||||
|
it('北京 8 月 30 日 08:00 出账:出账日 30 日,窗口为 29 日全天', () => {
|
||||||
|
const { start, end, billDate } = shanghaiT1DayWindow(new Date('2026-08-30T00:00:00.000Z'));
|
||||||
|
expect(shanghaiYmd(billDate)).toBe('2026-08-30');
|
||||||
|
expect(shanghaiYmd(start)).toBe('2026-08-29');
|
||||||
|
expect(end.toISOString()).toBe(billDate.toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('北京 8 月 31 日 00:05:出账日仍是 31 日,不是 UTC 的 30 日', () => {
|
||||||
|
const { billDate } = shanghaiT1DayWindow(new Date('2026-08-30T16:05:00.000Z'));
|
||||||
|
expect(shanghaiYmd(billDate)).toBe('2026-08-31');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shanghaiLaggedIssueWindow', () => {
|
||||||
|
it('酒厂 T+3:出账日是今天,纳入 3 天前完成的订单', () => {
|
||||||
|
const { start, end, billDate } = shanghaiLaggedIssueWindow(
|
||||||
|
new Date('2026-08-31T00:00:00.000Z'),
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
expect(shanghaiYmd(billDate)).toBe('2026-08-31');
|
||||||
|
expect(shanghaiYmd(start)).toBe('2026-08-28');
|
||||||
|
expect(shanghaiYmd(end)).toBe('2026-08-29');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addShanghaiDays', () => {
|
||||||
|
it('跨月跨年按北京日历加减', () => {
|
||||||
|
expect(shanghaiYmd(addShanghaiDays(new Date('2026-08-31T16:00:00.000Z'), 1))).toBe(
|
||||||
|
'2026-09-02',
|
||||||
|
);
|
||||||
|
expect(shanghaiYmd(addShanghaiDays(new Date('2026-01-01T00:00:00.000Z'), -1))).toBe(
|
||||||
|
'2025-12-31',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shanghaiMonthRange', () => {
|
||||||
|
it('8 月账期为北京 8/1 00:00 至 9/1 00:00(不含)', () => {
|
||||||
|
const { start, endExclusive } = shanghaiMonthRange(2026, 8);
|
||||||
|
expect(start.toISOString()).toBe('2026-07-31T16:00:00.000Z');
|
||||||
|
expect(endExclusive.toISOString()).toBe('2026-08-31T16:00:00.000Z');
|
||||||
|
expect(shanghaiYmd(shanghaiMonthLastInstant(2026, 8))).toBe('2026-08-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UTC 月末 23:59:59 不会把账期止日滚成下月', () => {
|
||||||
|
const periodStart = new Date('2026-08-01T00:00:00.000Z');
|
||||||
|
expect(shanghaiPeriodYmds(periodStart)).toEqual({
|
||||||
|
periodStart: '2026-08-01',
|
||||||
|
periodEnd: '2026-08-31',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('previousShanghaiMonth', () => {
|
||||||
|
it('北京 9 月 1 日 08:00(UTC 9/1 00:00)取 8 月', () => {
|
||||||
|
expect(previousShanghaiMonth(new Date('2026-09-01T00:00:00.000Z'))).toEqual({
|
||||||
|
year: 2026,
|
||||||
|
month: 8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('北京 9 月 1 日 00:30(UTC 8/31 16:30)取 8 月,不因 UTC 仍是 8 月而错到 7 月', () => {
|
||||||
|
expect(previousShanghaiMonth(new Date('2026-08-31T16:30:00.000Z'))).toEqual({
|
||||||
|
year: 2026,
|
||||||
|
month: 8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/** 财务账单日 / 账期一律按中国北京时间(Asia/Shanghai,无夏令时) */
|
||||||
|
|
||||||
|
export const SHANGHAI_TIME_ZONE = 'Asia/Shanghai';
|
||||||
|
|
||||||
|
function pad2(n: number): string {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shanghaiYmd(d: Date): string {
|
||||||
|
return d.toLocaleDateString('sv-SE', { timeZone: SHANGHAI_TIME_ZONE });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseShanghaiYmd(ymd: string): Date {
|
||||||
|
return new Date(`${ymd}T00:00:00+08:00`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfShanghaiDay(d: Date = new Date()): Date {
|
||||||
|
return parseShanghaiYmd(shanghaiYmd(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addShanghaiDays(d: Date, days: number): Date {
|
||||||
|
const [y, m, day] = shanghaiYmd(startOfShanghaiDay(d)).split('-').map(Number);
|
||||||
|
const utc = new Date(Date.UTC(y, m - 1, day + days));
|
||||||
|
const ymd = `${utc.getUTCFullYear()}-${pad2(utc.getUTCMonth() + 1)}-${pad2(utc.getUTCDate())}`;
|
||||||
|
return new Date(`${ymd}T00:00:00+08:00`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 门店 T+1:核销窗口 [昨日 00:00, 今日 00:00);出账日 = 今天 */
|
||||||
|
export function shanghaiT1DayWindow(anchor = new Date()) {
|
||||||
|
const billDate = startOfShanghaiDay(anchor);
|
||||||
|
const start = addShanghaiDays(billDate, -1);
|
||||||
|
return { start, end: billDate, billDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 酒厂 T+N:出账日 = 今天;纳入窗口为「今天 − lagDays」当天完成的订单
|
||||||
|
*/
|
||||||
|
export function shanghaiLaggedIssueWindow(anchor = new Date(), lagDays: number) {
|
||||||
|
const billDate = startOfShanghaiDay(anchor);
|
||||||
|
const start = addShanghaiDays(billDate, -lagDays);
|
||||||
|
const end = addShanghaiDays(start, 1);
|
||||||
|
return { start, end, billDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shanghaiMonthRange(year: number, month: number): { start: Date; endExclusive: Date } {
|
||||||
|
const start = new Date(`${year}-${pad2(month)}-01T00:00:00+08:00`);
|
||||||
|
const endMonth = month === 12 ? 1 : month + 1;
|
||||||
|
const endYear = month === 12 ? year + 1 : year;
|
||||||
|
const endExclusive = new Date(`${endYear}-${pad2(endMonth)}-01T00:00:00+08:00`);
|
||||||
|
return { start, endExclusive };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shanghaiMonthLastInstant(year: number, month: number): Date {
|
||||||
|
return new Date(shanghaiMonthRange(year, month).endExclusive.getTime() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previousShanghaiMonth(anchor = new Date()): { year: number; month: number } {
|
||||||
|
const [y, m] = shanghaiYmd(anchor).split('-').map(Number);
|
||||||
|
if (m === 1) return { year: y - 1, month: 12 };
|
||||||
|
return { year: y, month: m - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shanghaiYearMonth(anchor = new Date()): { year: number; month: number } {
|
||||||
|
const [y, m] = shanghaiYmd(anchor).split('-').map(Number);
|
||||||
|
return { year: y, month: m };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账期展示:以 periodStart 所在北京月的首末日,避免 UTC 23:59:59 滚到下月 */
|
||||||
|
export function shanghaiPeriodYmds(periodStart: Date): { periodStart: string; periodEnd: string } {
|
||||||
|
const [y, m] = shanghaiYmd(periodStart).split('-').map(Number);
|
||||||
|
const { start, endExclusive } = shanghaiMonthRange(y, m);
|
||||||
|
return {
|
||||||
|
periodStart: shanghaiYmd(start),
|
||||||
|
periodEnd: shanghaiYmd(new Date(endExclusive.getTime() - 1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -22,11 +22,11 @@ export const DEV_PLAN_TASK_TYPE_LABELS: Record<DevPlanTaskTypeDto, string> = {
|
|||||||
|
|
||||||
/** 开发计划任务状态 */
|
/** 开发计划任务状态 */
|
||||||
|
|
||||||
export type DevPlanTaskStatusDto = 'TODO' | 'DEVELOPED' | 'RELEASED';
|
export type DevPlanTaskStatusDto = 'TODO' | 'IN_PROGRESS' | 'DEVELOPED' | 'RELEASED';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const DEV_PLAN_TASK_STATUSES = ['TODO', 'DEVELOPED', 'RELEASED'] as const;
|
export const DEV_PLAN_TASK_STATUSES = ['TODO', 'IN_PROGRESS', 'DEVELOPED', 'RELEASED'] as const;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +34,8 @@ export const DEV_PLAN_TASK_STATUS_LABELS: Record<DevPlanTaskStatusDto, string> =
|
|||||||
|
|
||||||
TODO: '待开发',
|
TODO: '待开发',
|
||||||
|
|
||||||
|
IN_PROGRESS: '开发中',
|
||||||
|
|
||||||
DEVELOPED: '已开发',
|
DEVELOPED: '已开发',
|
||||||
|
|
||||||
RELEASED: '已上线',
|
RELEASED: '已上线',
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ export interface StoreBillDto {
|
|||||||
export interface WineryBillDto {
|
export interface WineryBillDto {
|
||||||
id: string;
|
id: string;
|
||||||
billNo: string;
|
billNo: string;
|
||||||
|
/** 出账日 YYYY-MM-DD(北京时间;T+3 只决定纳入哪天完成的订单,不是账单日) */
|
||||||
billDate: string;
|
billDate: string;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
orderAmount: number;
|
orderAmount: number;
|
||||||
@@ -212,7 +213,7 @@ export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6;
|
|||||||
/** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */
|
/** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */
|
||||||
export const WINERY_SETTLEMENT_RATE = 0.3;
|
export const WINERY_SETTLEMENT_RATE = 0.3;
|
||||||
|
|
||||||
/** 酒厂账单出账滞后自然日(T+3:订单完成日 + 3 天后纳入账单) */
|
/** 酒厂账单出账滞后自然日(T+3:订单完成日 + 3 天后纳入当天出账的账单;账单日=出账当天北京日历日) */
|
||||||
export const WINERY_SETTLEMENT_LAG_DAYS = 3;
|
export const WINERY_SETTLEMENT_LAG_DAYS = 3;
|
||||||
|
|
||||||
export type { LogisticsSettlementMethod } from './enums';
|
export type { LogisticsSettlementMethod } from './enums';
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 开发计划任务增加「开发中」状态
|
||||||
|
ALTER TABLE `dev_plan_task`
|
||||||
|
MODIFY COLUMN `status` ENUM('TODO', 'IN_PROGRESS', 'DEVELOPED', 'RELEASED') NOT NULL DEFAULT 'TODO';
|
||||||
@@ -126,6 +126,7 @@ enum DevPlanTaskType {
|
|||||||
|
|
||||||
enum DevPlanTaskStatus {
|
enum DevPlanTaskStatus {
|
||||||
TODO
|
TODO
|
||||||
|
IN_PROGRESS
|
||||||
DEVELOPED
|
DEVELOPED
|
||||||
RELEASED
|
RELEASED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const TOOL_INSTRUCTION = [
|
|||||||
'- finance_payout · finance_withdrawal',
|
'- finance_payout · finance_withdrawal',
|
||||||
'- dev_plan_tasks [状态] · dev_plan_versions [版本号]',
|
'- dev_plan_tasks [状态] · dev_plan_versions [版本号]',
|
||||||
'- dev_plan_task_create <BUG|REQUIREMENT|OPTIMIZATION> <描述>',
|
'- dev_plan_task_create <BUG|REQUIREMENT|OPTIMIZATION> <描述>',
|
||||||
'- dev_plan_task_update_status <任务编号> <TODO|DEVELOPED|RELEASED>',
|
'- dev_plan_task_update_status <任务编号> <TODO|IN_PROGRESS|DEVELOPED|RELEASED>',
|
||||||
'- dev_plan_version_create <版本号>',
|
'- dev_plan_version_create <版本号>',
|
||||||
'- dev_plan_version_update_status <版本号> <PENDING|IN_PROGRESS|TESTING|RELEASED>',
|
'- dev_plan_version_update_status <版本号> <PENDING|IN_PROGRESS|TESTING|RELEASED>',
|
||||||
'- dev_plan_version_link_tasks <版本号> <任务编号1,任务编号2>',
|
'- dev_plan_version_link_tasks <版本号> <任务编号1,任务编号2>',
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export class WecomBotCapabilityService {
|
|||||||
) {
|
) {
|
||||||
const cmds: string[] = [];
|
const cmds: string[] = [];
|
||||||
if (wecomBotHasPermission(bot, 'dev_plan.task.create')) cmds.push('`新建任务 BUG|需求|优化 <描述>`');
|
if (wecomBotHasPermission(bot, 'dev_plan.task.create')) cmds.push('`新建任务 BUG|需求|优化 <描述>`');
|
||||||
if (wecomBotHasPermission(bot, 'dev_plan.task.update_status')) cmds.push('`修改任务 <编号> TODO|DEVELOPED|RELEASED`');
|
if (wecomBotHasPermission(bot, 'dev_plan.task.update_status')) cmds.push('`修改任务 <编号> TODO|IN_PROGRESS|DEVELOPED|RELEASED`');
|
||||||
lines.push('**开发任务管理**', cmds.join(' · '), '');
|
lines.push('**开发任务管理**', cmds.join(' · '), '');
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -1234,19 +1234,20 @@ export class WecomBotCapabilityService {
|
|||||||
private async updateDevPlanTaskStatus(bot: WecomBotRuntimeConfig, wecomUserId: string, input: string) {
|
private async updateDevPlanTaskStatus(bot: WecomBotRuntimeConfig, wecomUserId: string, input: string) {
|
||||||
this.requirePerm(bot, 'dev_plan.task.update_status');
|
this.requirePerm(bot, 'dev_plan.task.update_status');
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
if (!trimmed) return '请提供任务编号和状态,格式:`修改任务 STxxx TODO|DEVELOPED|RELEASED`';
|
if (!trimmed) return '请提供任务编号和状态,格式:`修改任务 STxxx TODO|IN_PROGRESS|DEVELOPED|RELEASED`';
|
||||||
|
|
||||||
const parts = trimmed.split(/\s+/).filter(Boolean);
|
const parts = trimmed.split(/\s+/).filter(Boolean);
|
||||||
if (parts.length < 2) return '格式:`修改任务 <任务编号> <状态>`\n状态:TODO(待开发)· DEVELOPED(已开发)· RELEASED(已发布)';
|
if (parts.length < 2) return '格式:`修改任务 <任务编号> <状态>`\n状态:TODO(待开发)· IN_PROGRESS(开发中)· DEVELOPED(已开发)· RELEASED(已上线)';
|
||||||
|
|
||||||
const [taskNo, rawStatus] = parts;
|
const [taskNo, rawStatus] = parts;
|
||||||
const STATUS_ALIAS: Record<string, string> = {
|
const STATUS_ALIAS: Record<string, string> = {
|
||||||
TODO: 'TODO', todo: 'TODO', 待开发: 'TODO',
|
TODO: 'TODO', todo: 'TODO', 待开发: 'TODO',
|
||||||
|
IN_PROGRESS: 'IN_PROGRESS', in_progress: 'IN_PROGRESS', 开发中: 'IN_PROGRESS',
|
||||||
DEVELOPED: 'DEVELOPED', developed: 'DEVELOPED', 已开发: 'DEVELOPED',
|
DEVELOPED: 'DEVELOPED', developed: 'DEVELOPED', 已开发: 'DEVELOPED',
|
||||||
RELEASED: 'RELEASED', released: 'RELEASED', 已发布: 'RELEASED',
|
RELEASED: 'RELEASED', released: 'RELEASED', 已发布: 'RELEASED', 已上线: 'RELEASED',
|
||||||
};
|
};
|
||||||
const status = STATUS_ALIAS[rawStatus];
|
const status = STATUS_ALIAS[rawStatus];
|
||||||
if (!status) return `未知状态「${rawStatus}」。可用:TODO(待开发)/ DEVELOPED(已开发)/ RELEASED(已发布)`;
|
if (!status) return `未知状态「${rawStatus}」。可用:TODO(待开发)/ IN_PROGRESS(开发中)/ DEVELOPED(已开发)/ RELEASED(已上线)`;
|
||||||
|
|
||||||
const row = await this.prisma.devPlanTask.findFirst({ where: { taskNo } });
|
const row = await this.prisma.devPlanTask.findFirst({ where: { taskNo } });
|
||||||
if (!row) return `未找到任务「${taskNo}」`;
|
if (!row) return `未找到任务「${taskNo}」`;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Cron } from '@nestjs/schedule';
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { shanghaiYmd } from '@dukang/domain';
|
||||||
import { SettlementService } from '../modules/settlement/settlement.service';
|
import { SettlementService } from '../modules/settlement/settlement.service';
|
||||||
import { AlertService } from '../common/alert/alert.service';
|
import { AlertService } from '../common/alert/alert.service';
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ export class SettlementScheduler {
|
|||||||
category: 'settlement',
|
category: 'settlement',
|
||||||
title: '酒厂日账单任务失败',
|
title: '酒厂日账单任务失败',
|
||||||
detail: e instanceof Error ? e.message : String(e),
|
detail: e instanceof Error ? e.message : String(e),
|
||||||
dedupeKey: `job_winery_bill|${new Date().toISOString().slice(0, 10)}`,
|
dedupeKey: `job_winery_bill|${shanghaiYmd(new Date())}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -44,7 +45,7 @@ export class SettlementScheduler {
|
|||||||
category: 'settlement',
|
category: 'settlement',
|
||||||
title: '门店日账单任务失败',
|
title: '门店日账单任务失败',
|
||||||
detail: e instanceof Error ? e.message : String(e),
|
detail: e instanceof Error ? e.message : String(e),
|
||||||
dedupeKey: `job_store_bill|${new Date().toISOString().slice(0, 10)}`,
|
dedupeKey: `job_store_bill|${shanghaiYmd(new Date())}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -506,9 +506,9 @@ export class DevPlanService {
|
|||||||
|
|
||||||
const completed = taskCompletedAtOnStatus(
|
const completed = taskCompletedAtOnStatus(
|
||||||
|
|
||||||
existing.status as 'TODO' | 'DEVELOPED' | 'RELEASED',
|
existing.status as DevPlanTaskStatus,
|
||||||
|
|
||||||
dto.status as 'TODO' | 'DEVELOPED' | 'RELEASED',
|
dto.status as DevPlanTaskStatus,
|
||||||
|
|
||||||
existing.completedAt,
|
existing.completedAt,
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,21 @@ import {
|
|||||||
WINERY_SETTLEMENT_RATE,
|
WINERY_SETTLEMENT_RATE,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import {
|
import {
|
||||||
|
addShanghaiDays,
|
||||||
calcLogisticsFeeByBottles,
|
calcLogisticsFeeByBottles,
|
||||||
calcRedeemSettleAmount,
|
calcRedeemSettleAmount,
|
||||||
|
parseShanghaiYmd,
|
||||||
pickPayoutsForWithdrawAmount,
|
pickPayoutsForWithdrawAmount,
|
||||||
|
previousShanghaiMonth,
|
||||||
resolveSettlementRate,
|
resolveSettlementRate,
|
||||||
|
shanghaiLaggedIssueWindow,
|
||||||
|
shanghaiMonthLastInstant,
|
||||||
|
shanghaiMonthRange,
|
||||||
|
shanghaiPeriodYmds,
|
||||||
|
shanghaiT1DayWindow,
|
||||||
|
shanghaiYearMonth,
|
||||||
|
shanghaiYmd,
|
||||||
|
startOfShanghaiDay,
|
||||||
sumUnbilledPayoutAmount,
|
sumUnbilledPayoutAmount,
|
||||||
validateStoreWithdraw,
|
validateStoreWithdraw,
|
||||||
type LogisticsPricingRule,
|
type LogisticsPricingRule,
|
||||||
@@ -64,30 +75,8 @@ function paymentProofUrlsInput(urls?: string[]): Prisma.InputJsonValue | typeof
|
|||||||
return parsed.length ? (parsed as Prisma.InputJsonValue) : Prisma.JsonNull;
|
return parsed.length ? (parsed as Prisma.InputJsonValue) : Prisma.JsonNull;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上海时区自然日 00:00(用本地 Date 构造;服务器需设 Asia/Shanghai 或等价) */
|
function withShanghaiPeriod<T extends { periodStart: Date; periodEnd: Date }>(row: T) {
|
||||||
function startOfDay(d: Date) {
|
return { ...row, ...shanghaiPeriodYmds(row.periodStart) };
|
||||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 核销窗口:昨日 00:00 ≤ t < 今日 00:00;出账日 = 今日 00:00(窗口右端) */
|
|
||||||
function dayWindow(anchor = new Date()) {
|
|
||||||
const end = startOfDay(anchor);
|
|
||||||
const start = new Date(end);
|
|
||||||
start.setDate(start.getDate() - 1);
|
|
||||||
return { start, end, billDate: end };
|
|
||||||
}
|
|
||||||
|
|
||||||
function shanghaiYmd(d: Date): string {
|
|
||||||
return d.toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function wineryDayWindow(anchor = new Date(), lagDays = WINERY_SETTLEMENT_LAG_DAYS) {
|
|
||||||
const billDate = startOfDay(anchor);
|
|
||||||
billDate.setDate(billDate.getDate() - lagDays);
|
|
||||||
const start = billDate;
|
|
||||||
const end = new Date(start);
|
|
||||||
end.setDate(end.getDate() + 1);
|
|
||||||
return { start, end, billDate: start };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPartnerBillItemDto(row: {
|
function toPartnerBillItemDto(row: {
|
||||||
@@ -180,17 +169,11 @@ function getStoreWithdrawDailyLimit(): number {
|
|||||||
|
|
||||||
/** 工作日 18:00 前未审完视为 FIN-003 超时(Asia/Shanghai 自然日) */
|
/** 工作日 18:00 前未审完视为 FIN-003 超时(Asia/Shanghai 自然日) */
|
||||||
function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean {
|
function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean {
|
||||||
const day = appliedAt.getDay(); // 0 Sun … 6 Sat
|
const ymd = shanghaiYmd(appliedAt);
|
||||||
|
const noon = new Date(`${ymd}T12:00:00+08:00`);
|
||||||
|
const day = noon.getUTCDay(); // 与北京日历日相同
|
||||||
if (day === 0 || day === 6) return false;
|
if (day === 0 || day === 6) return false;
|
||||||
const deadline = new Date(
|
const deadline = new Date(`${ymd}T18:00:00+08:00`);
|
||||||
appliedAt.getFullYear(),
|
|
||||||
appliedAt.getMonth(),
|
|
||||||
appliedAt.getDate(),
|
|
||||||
18,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
return now.getTime() > deadline.getTime();
|
return now.getTime() > deadline.getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,15 +193,21 @@ export class SettlementService implements OnModuleInit {
|
|||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
try {
|
try {
|
||||||
const shifted = await this.realignStoreBillIssueDates();
|
const shifted = await this.realignStoreBillIssueDates();
|
||||||
if (shifted > 0) this.logger.log(`Store bill dates aligned to issue day: ${shifted}`);
|
if (shifted > 0) this.logger.log(`Store bill dates aligned to Beijing issue day: ${shifted}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.warn(`Store bill date align skipped: ${e instanceof Error ? e.message : e}`);
|
this.logger.warn(`Store bill date align skipped: ${e instanceof Error ? e.message : e}`);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const shifted = await this.realignWineryBillIssueDates();
|
||||||
|
if (shifted > 0) this.logger.log(`Winery bill dates aligned to Beijing issue day: ${shifted}`);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`Winery bill date align skipped: ${e instanceof Error ? e.message : e}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 旧数据 billDate=核销自然日(窗口左端);现改为出账日(窗口右端=今天)。
|
* 账单日 = 出账当天(created_at 的北京日历日 00:00+08)。
|
||||||
* 按 created_at 日历日对比,只把仍早一天的行 +1;从晚到早更新避免 (storeId,billDate) 冲突。
|
* 纠正:旧窗口左端、UTC DATE() +1 误伤、以及 toISOString 跨日。
|
||||||
*/
|
*/
|
||||||
private async realignStoreBillIssueDates(): Promise<number> {
|
private async realignStoreBillIssueDates(): Promise<number> {
|
||||||
const locked = await this.prisma.$queryRaw<Array<{ acquired: number | bigint | null }>>`
|
const locked = await this.prisma.$queryRaw<Array<{ acquired: number | bigint | null }>>`
|
||||||
@@ -226,18 +215,53 @@ export class SettlementService implements OnModuleInit {
|
|||||||
`;
|
`;
|
||||||
if (!Number(locked[0]?.acquired)) return 0;
|
if (!Number(locked[0]?.acquired)) return 0;
|
||||||
try {
|
try {
|
||||||
const shifted = await this.prisma.$executeRaw`
|
const bills = await this.prisma.storeBill.findMany({
|
||||||
UPDATE store_bill
|
select: { id: true, storeId: true, billDate: true, createdAt: true },
|
||||||
SET bill_date = DATE_ADD(bill_date, INTERVAL 1 DAY)
|
orderBy: { createdAt: 'desc' },
|
||||||
WHERE DATE(bill_date) < DATE(created_at)
|
});
|
||||||
ORDER BY bill_date DESC
|
let shifted = 0;
|
||||||
`;
|
for (const b of bills) {
|
||||||
return Number(shifted);
|
const issue = startOfShanghaiDay(b.createdAt);
|
||||||
|
if (shanghaiYmd(b.billDate) === shanghaiYmd(issue)) continue;
|
||||||
|
const clash = await this.prisma.storeBill.findUnique({
|
||||||
|
where: { storeId_billDate: { storeId: b.storeId, billDate: issue } },
|
||||||
|
});
|
||||||
|
if (clash && clash.id !== b.id) continue;
|
||||||
|
await this.prisma.storeBill.update({ where: { id: b.id }, data: { billDate: issue } });
|
||||||
|
shifted += 1;
|
||||||
|
}
|
||||||
|
return shifted;
|
||||||
} finally {
|
} finally {
|
||||||
await this.prisma.$queryRaw`SELECT RELEASE_LOCK('store_bill_issue_date_align')`;
|
await this.prisma.$queryRaw`SELECT RELEASE_LOCK('store_bill_issue_date_align')`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 酒厂账单日改为出账当天(不再存 T+3 完成日) */
|
||||||
|
private async realignWineryBillIssueDates(): Promise<number> {
|
||||||
|
const locked = await this.prisma.$queryRaw<Array<{ acquired: number | bigint | null }>>`
|
||||||
|
SELECT GET_LOCK('winery_bill_issue_date_align', 5) AS acquired
|
||||||
|
`;
|
||||||
|
if (!Number(locked[0]?.acquired)) return 0;
|
||||||
|
try {
|
||||||
|
const bills = await this.prisma.wineryBill.findMany({
|
||||||
|
select: { id: true, billDate: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
let shifted = 0;
|
||||||
|
for (const b of bills) {
|
||||||
|
const issue = startOfShanghaiDay(b.createdAt);
|
||||||
|
if (shanghaiYmd(b.billDate) === shanghaiYmd(issue)) continue;
|
||||||
|
const clash = await this.prisma.wineryBill.findUnique({ where: { billDate: issue } });
|
||||||
|
if (clash && clash.id !== b.id) continue;
|
||||||
|
await this.prisma.wineryBill.update({ where: { id: b.id }, data: { billDate: issue } });
|
||||||
|
shifted += 1;
|
||||||
|
}
|
||||||
|
return shifted;
|
||||||
|
} finally {
|
||||||
|
await this.prisma.$queryRaw`SELECT RELEASE_LOCK('winery_bill_issue_date_align')`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private notifyPartnerBillDigest(
|
private notifyPartnerBillDigest(
|
||||||
period: string,
|
period: string,
|
||||||
rows: Array<{
|
rows: Array<{
|
||||||
@@ -424,9 +448,8 @@ export class SettlementService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async todayWithdrawAppliedAmount(storeId: bigint, now = new Date()) {
|
private async todayWithdrawAppliedAmount(storeId: bigint, now = new Date()) {
|
||||||
const start = startOfDay(now);
|
const start = startOfShanghaiDay(now);
|
||||||
const end = new Date(start);
|
const end = addShanghaiDays(start, 1);
|
||||||
end.setDate(end.getDate() + 1);
|
|
||||||
const agg = await this.prisma.storeWithdrawRequest.aggregate({
|
const agg = await this.prisma.storeWithdrawRequest.aggregate({
|
||||||
where: {
|
where: {
|
||||||
storeId,
|
storeId,
|
||||||
@@ -897,7 +920,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
...lines,
|
...lines,
|
||||||
'请尽快在 HQ「财务 → 门店账单(手动提现)」处理。',
|
'请尽快在 HQ「财务 → 门店账单(手动提现)」处理。',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
dedupeKey: `store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`,
|
dedupeKey: `store_withdraw_overdue|${shanghaiYmd(new Date())}`,
|
||||||
dedupeTtlSec: 6 * 3600,
|
dedupeTtlSec: 6 * 3600,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1075,9 +1098,9 @@ export class SettlementService implements OnModuleInit {
|
|||||||
|
|
||||||
// ─── Store bills (daily header) ──────────────────────
|
// ─── Store bills (daily header) ──────────────────────
|
||||||
|
|
||||||
/** 生成昨日核销窗口的门店对账单;billDate = 出账日(今日) */
|
/** 生成昨日核销窗口的门店对账单;billDate = 出账日(北京时间今天) */
|
||||||
async generateStoreBillsForDay(anchor = new Date()) {
|
async generateStoreBillsForDay(anchor = new Date()) {
|
||||||
const { start, end, billDate } = dayWindow(anchor);
|
const { start, end, billDate } = shanghaiT1DayWindow(anchor);
|
||||||
const payouts = await this.prisma.storePayout.findMany({
|
const payouts = await this.prisma.storePayout.findMany({
|
||||||
where: {
|
where: {
|
||||||
storeBillId: null,
|
storeBillId: null,
|
||||||
@@ -1275,12 +1298,8 @@ export class SettlementService implements OnModuleInit {
|
|||||||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||||
if (query.dateFrom || query.dateTo) {
|
if (query.dateFrom || query.dateTo) {
|
||||||
where.appliedAt = {};
|
where.appliedAt = {};
|
||||||
if (query.dateFrom) where.appliedAt.gte = new Date(query.dateFrom);
|
if (query.dateFrom) where.appliedAt.gte = parseShanghaiYmd(query.dateFrom);
|
||||||
if (query.dateTo) {
|
if (query.dateTo) where.appliedAt.lt = addShanghaiDays(parseShanghaiYmd(query.dateTo), 1);
|
||||||
const end = new Date(query.dateTo);
|
|
||||||
end.setHours(23, 59, 59, 999);
|
|
||||||
where.appliedAt.lte = end;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return where;
|
return where;
|
||||||
})()
|
})()
|
||||||
@@ -1564,8 +1583,8 @@ export class SettlementService implements OnModuleInit {
|
|||||||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||||
if (query.dateFrom || query.dateTo) {
|
if (query.dateFrom || query.dateTo) {
|
||||||
where.billDate = {};
|
where.billDate = {};
|
||||||
if (query.dateFrom) where.billDate.gte = startOfDay(new Date(query.dateFrom));
|
if (query.dateFrom) where.billDate.gte = parseShanghaiYmd(query.dateFrom);
|
||||||
if (query.dateTo) where.billDate.lte = startOfDay(new Date(query.dateTo));
|
if (query.dateTo) where.billDate.lte = parseShanghaiYmd(query.dateTo);
|
||||||
}
|
}
|
||||||
return where;
|
return where;
|
||||||
}
|
}
|
||||||
@@ -1598,7 +1617,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
eventName: 'partner_bill_view',
|
eventName: 'partner_bill_view',
|
||||||
extraJson: { count: bills.length },
|
extraJson: { count: bills.length },
|
||||||
});
|
});
|
||||||
return serializeBigInt(bills);
|
return serializeBigInt(bills.map(withShanghaiPeriod));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPartnerBill(partnerAccountId: bigint, billId: bigint) {
|
async getPartnerBill(partnerAccountId: bigint, billId: bigint) {
|
||||||
@@ -1617,7 +1636,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
const { items, ...header } = bill;
|
const { items, ...header } = bill;
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
...header,
|
...withShanghaiPeriod(header),
|
||||||
partnerId: header.partnerAccountId.toString(),
|
partnerId: header.partnerAccountId.toString(),
|
||||||
...splitPartnerBillItems(items),
|
...splitPartnerBillItems(items),
|
||||||
});
|
});
|
||||||
@@ -1664,7 +1683,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const items = rawItems.map((b) => ({
|
const items = rawItems.map((b) => ({
|
||||||
...b,
|
...withShanghaiPeriod(b),
|
||||||
partner: b.partnerAccount,
|
partner: b.partnerAccount,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1688,9 +1707,8 @@ export class SettlementService implements OnModuleInit {
|
|||||||
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
|
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
|
||||||
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
||||||
if (query.year && query.month) {
|
if (query.year && query.month) {
|
||||||
const periodStart = new Date(query.year, query.month - 1, 1);
|
const { start, endExclusive } = shanghaiMonthRange(query.year, query.month);
|
||||||
const periodEnd = new Date(query.year, query.month, 0, 23, 59, 59, 999);
|
where.periodStart = { gte: start, lt: endExclusive };
|
||||||
where.periodStart = { gte: periodStart, lte: periodEnd };
|
|
||||||
}
|
}
|
||||||
return where;
|
return where;
|
||||||
}
|
}
|
||||||
@@ -1767,8 +1785,8 @@ export class SettlementService implements OnModuleInit {
|
|||||||
|
|
||||||
/** 每月 1 日任务:生成上一自然月账单 */
|
/** 每月 1 日任务:生成上一自然月账单 */
|
||||||
async generatePreviousMonthPartnerBills(anchor = new Date()) {
|
async generatePreviousMonthPartnerBills(anchor = new Date()) {
|
||||||
const prev = new Date(anchor.getFullYear(), anchor.getMonth() - 1, 1);
|
const prev = previousShanghaiMonth(anchor);
|
||||||
return this.generateAllPartnerBills({ year: prev.getFullYear(), month: prev.getMonth() + 1 });
|
return this.generateAllPartnerBills({ year: prev.year, month: prev.month });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAdminPartnerBill(id: bigint) {
|
async getAdminPartnerBill(id: bigint) {
|
||||||
@@ -1779,7 +1797,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
if (!bill) throw new NotFoundException('账单不存在');
|
if (!bill) throw new NotFoundException('账单不存在');
|
||||||
const { items, ...header } = bill;
|
const { items, ...header } = bill;
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
...header,
|
...withShanghaiPeriod(header),
|
||||||
partnerId: header.partnerAccountId.toString(),
|
partnerId: header.partnerAccountId.toString(),
|
||||||
...splitPartnerBillItems(items),
|
...splitPartnerBillItems(items),
|
||||||
});
|
});
|
||||||
@@ -1791,12 +1809,13 @@ export class SettlementService implements OnModuleInit {
|
|||||||
) {
|
) {
|
||||||
const partnerAccountId = BigInt(body.partnerId);
|
const partnerAccountId = BigInt(body.partnerId);
|
||||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||||
const periodStart = new Date(body.year, body.month - 1, 1);
|
const { start: periodStart, endExclusive } = shanghaiMonthRange(body.year, body.month);
|
||||||
const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999);
|
const periodEnd = shanghaiMonthLastInstant(body.year, body.month);
|
||||||
|
|
||||||
const existing = await this.prisma.partnerBill.findUnique({
|
const existing = await this.prisma.partnerBill.findFirst({
|
||||||
where: {
|
where: {
|
||||||
partnerAccountId_periodStart: { partnerAccountId: primary.id, periodStart },
|
partnerAccountId: primary.id,
|
||||||
|
periodStart: { gte: periodStart, lt: endExclusive },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (existing && existing.status !== 'PENDING_REVIEW') {
|
if (existing && existing.status !== 'PENDING_REVIEW') {
|
||||||
@@ -1876,6 +1895,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
orderCommission: round2(orderCommission),
|
orderCommission: round2(orderCommission),
|
||||||
redeemCommission: round2(redeemCommission),
|
redeemCommission: round2(redeemCommission),
|
||||||
totalAmount,
|
totalAmount,
|
||||||
|
periodStart,
|
||||||
periodEnd,
|
periodEnd,
|
||||||
status: 'PENDING_REVIEW',
|
status: 'PENDING_REVIEW',
|
||||||
},
|
},
|
||||||
@@ -2142,15 +2162,15 @@ export class SettlementService implements OnModuleInit {
|
|||||||
csvEscape(b.billNo),
|
csvEscape(b.billNo),
|
||||||
csvEscape(b.partnerAccount.companyName ?? ''),
|
csvEscape(b.partnerAccount.companyName ?? ''),
|
||||||
csvEscape(b.partnerAccount.phone ?? ''),
|
csvEscape(b.partnerAccount.phone ?? ''),
|
||||||
b.periodStart.toISOString().slice(0, 10),
|
shanghaiPeriodYmds(b.periodStart).periodStart,
|
||||||
b.periodEnd.toISOString().slice(0, 10),
|
shanghaiPeriodYmds(b.periodStart).periodEnd,
|
||||||
Number(b.orderCommission),
|
Number(b.orderCommission),
|
||||||
Number(b.redeemCommission),
|
Number(b.redeemCommission),
|
||||||
Number(b.totalAmount),
|
Number(b.totalAmount),
|
||||||
b.status,
|
b.status,
|
||||||
b.sentAt ? b.sentAt.toISOString().slice(0, 10) : '',
|
b.sentAt ? shanghaiYmd(b.sentAt) : '',
|
||||||
b.confirmedAt ? b.confirmedAt.toISOString().slice(0, 10) : '',
|
b.confirmedAt ? shanghaiYmd(b.confirmedAt) : '',
|
||||||
b.paidAt ? b.paidAt.toISOString().slice(0, 10) : '',
|
b.paidAt ? shanghaiYmd(b.paidAt) : '',
|
||||||
csvEscape(b.paymentRef ?? ''),
|
csvEscape(b.paymentRef ?? ''),
|
||||||
csvEscape(b.partnerAccount.bankAccountName ?? ''),
|
csvEscape(b.partnerAccount.bankAccountName ?? ''),
|
||||||
csvEscape(b.partnerAccount.bankAccountNo ?? ''),
|
csvEscape(b.partnerAccount.bankAccountNo ?? ''),
|
||||||
@@ -2182,15 +2202,20 @@ export class SettlementService implements OnModuleInit {
|
|||||||
// ─── Winery bills ────────────────────────────────────
|
// ─── Winery bills ────────────────────────────────────
|
||||||
|
|
||||||
async generateWineryBillForDay(anchor = new Date()) {
|
async generateWineryBillForDay(anchor = new Date()) {
|
||||||
const { start, end, billDate } = wineryDayWindow(anchor);
|
const { start, end, billDate } = shanghaiLaggedIssueWindow(anchor, WINERY_SETTLEMENT_LAG_DAYS);
|
||||||
const rate = WINERY_SETTLEMENT_RATE;
|
const rate = WINERY_SETTLEMENT_RATE;
|
||||||
|
const issueYmd = shanghaiYmd(billDate);
|
||||||
|
|
||||||
const existing = await this.prisma.wineryBill.findUnique({ where: { billDate } });
|
const existing =
|
||||||
|
(await this.prisma.wineryBill.findUnique({ where: { billDate } })) ??
|
||||||
|
(await this.prisma.wineryBill.findFirst({
|
||||||
|
where: { createdAt: { gte: billDate, lt: addShanghaiDays(billDate, 1) } },
|
||||||
|
}));
|
||||||
if (existing?.status === 'PAID') {
|
if (existing?.status === 'PAID') {
|
||||||
return { billDate: billDate.toISOString().slice(0, 10), skipped: true, reason: '已打款' };
|
return { billDate: issueYmd, skipped: true, reason: '已打款' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// T+3:纳入「账单日」当天完成的同城/跨城订单(完成日 = 今天 − lagDays)
|
// T+3:纳入「今天 − lagDays」当天完成的同城/跨城订单;billDate = 出账日(今天)
|
||||||
const orders = await this.prisma.order.findMany({
|
const orders = await this.prisma.order.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: 'COMPLETED',
|
status: 'COMPLETED',
|
||||||
@@ -2203,7 +2228,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (orders.length === 0 && !existing) {
|
if (orders.length === 0 && !existing) {
|
||||||
return { billDate: billDate.toISOString().slice(0, 10), skipped: true, reason: '无订单' };
|
return { billDate: issueYmd, skipped: true, reason: '无订单' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderAmount = round2(orders.reduce((s, o) => s + Number(o.payAmount), 0));
|
const orderAmount = round2(orders.reduce((s, o) => s + Number(o.payAmount), 0));
|
||||||
@@ -2214,6 +2239,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
? await tx.wineryBill.update({
|
? await tx.wineryBill.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: {
|
data: {
|
||||||
|
billDate,
|
||||||
orderCount: orders.length,
|
orderCount: orders.length,
|
||||||
orderAmount,
|
orderAmount,
|
||||||
wineryRate: rate,
|
wineryRate: rate,
|
||||||
@@ -2254,7 +2280,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
return header;
|
return header;
|
||||||
});
|
});
|
||||||
|
|
||||||
const period = billDate.toISOString().slice(0, 10);
|
const period = issueYmd;
|
||||||
const wineryBank = await loadWineryBankConfig(this.prisma);
|
const wineryBank = await loadWineryBankConfig(this.prisma);
|
||||||
const bankParts = wecomBankParts(wineryBank);
|
const bankParts = wecomBankParts(wineryBank);
|
||||||
const cityNames = joinLimited(
|
const cityNames = joinLimited(
|
||||||
@@ -2324,7 +2350,13 @@ export class SettlementService implements OnModuleInit {
|
|||||||
totalAmount: Number(aggregates._sum.wineryAmount ?? 0),
|
totalAmount: Number(aggregates._sum.wineryAmount ?? 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
return serializeBigInt({ items, total, page, pageSize, summary });
|
return serializeBigInt({
|
||||||
|
items: items.map((b) => ({ ...b, billDate: shanghaiYmd(b.billDate) })),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
summary,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAdminWineryBill(id: bigint) {
|
async getAdminWineryBill(id: bigint) {
|
||||||
@@ -2334,7 +2366,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
if (!bill) throw new NotFoundException('酒厂对账单不存在');
|
if (!bill) throw new NotFoundException('酒厂对账单不存在');
|
||||||
const wineryBank = await loadWineryBankConfig(this.prisma);
|
const wineryBank = await loadWineryBankConfig(this.prisma);
|
||||||
return serializeBigInt({ ...bill, wineryBank });
|
return serializeBigInt({ ...bill, billDate: shanghaiYmd(bill.billDate), wineryBank });
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmWineryBill(id: bigint, dto: { paymentRef?: string } = {}) {
|
async confirmWineryBill(id: bigint, dto: { paymentRef?: string } = {}) {
|
||||||
@@ -2418,7 +2450,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
rows.push(
|
rows.push(
|
||||||
[
|
[
|
||||||
csvEscape(b.billNo),
|
csvEscape(b.billNo),
|
||||||
b.billDate.toISOString().slice(0, 10),
|
shanghaiYmd(b.billDate),
|
||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
Number(b.orderAmount),
|
Number(b.orderAmount),
|
||||||
@@ -2435,7 +2467,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
rows.push(
|
rows.push(
|
||||||
[
|
[
|
||||||
csvEscape(b.billNo),
|
csvEscape(b.billNo),
|
||||||
b.billDate.toISOString().slice(0, 10),
|
shanghaiYmd(b.billDate),
|
||||||
csvEscape(item.orderNo),
|
csvEscape(item.orderNo),
|
||||||
item.deliveryType === 'LOCAL' ? '同城' : item.deliveryType === 'CROSS_CITY' ? '跨城' : item.deliveryType,
|
item.deliveryType === 'LOCAL' ? '同城' : item.deliveryType === 'CROSS_CITY' ? '跨城' : item.deliveryType,
|
||||||
Number(item.payAmount),
|
Number(item.payAmount),
|
||||||
@@ -2469,13 +2501,12 @@ export class SettlementService implements OnModuleInit {
|
|||||||
where.wineryAmount = { gt: 0 };
|
where.wineryAmount = { gt: 0 };
|
||||||
}
|
}
|
||||||
if (query.year && query.month) {
|
if (query.year && query.month) {
|
||||||
const start = new Date(query.year, query.month - 1, 1);
|
const { start, endExclusive } = shanghaiMonthRange(query.year, query.month);
|
||||||
const end = new Date(query.year, query.month, 0, 23, 59, 59, 999);
|
where.billDate = { gte: start, lt: endExclusive };
|
||||||
where.billDate = { gte: start, lte: end };
|
|
||||||
} else if (query.dateFrom || query.dateTo) {
|
} else if (query.dateFrom || query.dateTo) {
|
||||||
where.billDate = {};
|
where.billDate = {};
|
||||||
if (query.dateFrom) where.billDate.gte = startOfDay(new Date(query.dateFrom));
|
if (query.dateFrom) where.billDate.gte = parseShanghaiYmd(query.dateFrom);
|
||||||
if (query.dateTo) where.billDate.lte = startOfDay(new Date(query.dateTo));
|
if (query.dateTo) where.billDate.lte = parseShanghaiYmd(query.dateTo);
|
||||||
}
|
}
|
||||||
return where;
|
return where;
|
||||||
}
|
}
|
||||||
@@ -2483,10 +2514,10 @@ export class SettlementService implements OnModuleInit {
|
|||||||
// ─── Logistics bills (按承运商月结) ───────────────────
|
// ─── Logistics bills (按承运商月结) ───────────────────
|
||||||
|
|
||||||
async generatePreviousMonthLogisticsBills(anchor = new Date()) {
|
async generatePreviousMonthLogisticsBills(anchor = new Date()) {
|
||||||
const prev = new Date(anchor.getFullYear(), anchor.getMonth() - 1, 1);
|
const prev = previousShanghaiMonth(anchor);
|
||||||
return this.generateAllLogisticsBills({
|
return this.generateAllLogisticsBills({
|
||||||
year: prev.getFullYear(),
|
year: prev.year,
|
||||||
month: prev.getMonth() + 1,
|
month: prev.month,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2590,12 +2621,13 @@ export class SettlementService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
if (!provider) throw new NotFoundException('仓配承运商不存在');
|
if (!provider) throw new NotFoundException('仓配承运商不存在');
|
||||||
|
|
||||||
const periodStart = new Date(body.year, body.month - 1, 1);
|
const { start: periodStart, endExclusive } = shanghaiMonthRange(body.year, body.month);
|
||||||
const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999);
|
const periodEnd = shanghaiMonthLastInstant(body.year, body.month);
|
||||||
|
|
||||||
const existing = await this.prisma.logisticsBill.findUnique({
|
const existing = await this.prisma.logisticsBill.findFirst({
|
||||||
where: {
|
where: {
|
||||||
fulfillmentProviderId_periodStart: { fulfillmentProviderId, periodStart },
|
fulfillmentProviderId,
|
||||||
|
periodStart: { gte: periodStart, lt: endExclusive },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (existing?.status === 'PAID') {
|
if (existing?.status === 'PAID') {
|
||||||
@@ -2676,6 +2708,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
? await tx.logisticsBill.update({
|
? await tx.logisticsBill.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: {
|
data: {
|
||||||
|
periodStart,
|
||||||
periodEnd,
|
periodEnd,
|
||||||
orderCount,
|
orderCount,
|
||||||
bottleCount,
|
bottleCount,
|
||||||
@@ -2814,7 +2847,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const items = rawItems.map((b) => ({
|
const items = rawItems.map((b) => ({
|
||||||
...b,
|
...withShanghaiPeriod(b),
|
||||||
providerCode: b.fulfillmentProvider.code,
|
providerCode: b.fulfillmentProvider.code,
|
||||||
providerName: b.fulfillmentProvider.name,
|
providerName: b.fulfillmentProvider.name,
|
||||||
pricingSnapshot: this.fulfillmentProviderService.parsePricingRules(b.pricingSnapshotJson),
|
pricingSnapshot: this.fulfillmentProviderService.parsePricingRules(b.pricingSnapshotJson),
|
||||||
@@ -2831,10 +2864,11 @@ export class SettlementService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listLogisticsProviderSummary(query: { year?: number; month?: number }) {
|
async listLogisticsProviderSummary(query: { year?: number; month?: number }) {
|
||||||
const year = query.year ?? new Date().getFullYear();
|
const fallback = shanghaiYearMonth();
|
||||||
const month = query.month ?? new Date().getMonth() + 1;
|
const year = query.year ?? fallback.year;
|
||||||
const periodStart = new Date(year, month - 1, 1);
|
const month = query.month ?? fallback.month;
|
||||||
const periodEnd = new Date(year, month, 0, 23, 59, 59, 999);
|
const { start: periodStart, endExclusive } = shanghaiMonthRange(year, month);
|
||||||
|
const periodEnd = new Date(endExclusive.getTime() - 1);
|
||||||
|
|
||||||
const providers = await this.prisma.fulfillmentProvider.findMany({
|
const providers = await this.prisma.fulfillmentProvider.findMany({
|
||||||
orderBy: { code: 'asc' },
|
orderBy: { code: 'asc' },
|
||||||
@@ -2897,12 +2931,10 @@ export class SettlementService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bill = await this.prisma.logisticsBill.findUnique({
|
const bill = await this.prisma.logisticsBill.findFirst({
|
||||||
where: {
|
where: {
|
||||||
fulfillmentProviderId_periodStart: {
|
|
||||||
fulfillmentProviderId: p.id,
|
fulfillmentProviderId: p.id,
|
||||||
periodStart,
|
periodStart: { gte: periodStart, lt: endExclusive },
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2938,7 +2970,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
if (!bill) throw new NotFoundException('物流对账单不存在');
|
if (!bill) throw new NotFoundException('物流对账单不存在');
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
...bill,
|
...withShanghaiPeriod(bill),
|
||||||
providerCode: bill.fulfillmentProvider.code,
|
providerCode: bill.fulfillmentProvider.code,
|
||||||
providerName: bill.fulfillmentProvider.name,
|
providerName: bill.fulfillmentProvider.name,
|
||||||
pricingSnapshot: this.fulfillmentProviderService.parsePricingRules(bill.pricingSnapshotJson),
|
pricingSnapshot: this.fulfillmentProviderService.parsePricingRules(bill.pricingSnapshotJson),
|
||||||
@@ -3056,8 +3088,8 @@ export class SettlementService implements OnModuleInit {
|
|||||||
csvEscape(b.billNo),
|
csvEscape(b.billNo),
|
||||||
csvEscape(p.code),
|
csvEscape(p.code),
|
||||||
csvEscape(p.name),
|
csvEscape(p.name),
|
||||||
b.periodStart.toISOString().slice(0, 10),
|
shanghaiPeriodYmds(b.periodStart).periodStart,
|
||||||
b.periodEnd.toISOString().slice(0, 10),
|
shanghaiPeriodYmds(b.periodStart).periodEnd,
|
||||||
b.settlementMethod,
|
b.settlementMethod,
|
||||||
'',
|
'',
|
||||||
b.bottleCount,
|
b.bottleCount,
|
||||||
@@ -3075,8 +3107,8 @@ export class SettlementService implements OnModuleInit {
|
|||||||
csvEscape(b.billNo),
|
csvEscape(b.billNo),
|
||||||
csvEscape(p.code),
|
csvEscape(p.code),
|
||||||
csvEscape(p.name),
|
csvEscape(p.name),
|
||||||
b.periodStart.toISOString().slice(0, 10),
|
shanghaiPeriodYmds(b.periodStart).periodStart,
|
||||||
b.periodEnd.toISOString().slice(0, 10),
|
shanghaiPeriodYmds(b.periodStart).periodEnd,
|
||||||
b.settlementMethod,
|
b.settlementMethod,
|
||||||
csvEscape(item.orderNo),
|
csvEscape(item.orderNo),
|
||||||
item.quantity,
|
item.quantity,
|
||||||
@@ -3101,9 +3133,8 @@ export class SettlementService implements OnModuleInit {
|
|||||||
if (query.status === 'UNPAID' || query.status === 'PAID') where.status = query.status;
|
if (query.status === 'UNPAID' || query.status === 'PAID') where.status = query.status;
|
||||||
if (query.providerId) where.fulfillmentProviderId = BigInt(query.providerId);
|
if (query.providerId) where.fulfillmentProviderId = BigInt(query.providerId);
|
||||||
if (query.year && query.month) {
|
if (query.year && query.month) {
|
||||||
const periodStart = new Date(query.year, query.month - 1, 1);
|
const { start, endExclusive } = shanghaiMonthRange(query.year, query.month);
|
||||||
const periodEnd = new Date(query.year, query.month, 0, 23, 59, 59, 999);
|
where.periodStart = { gte: start, lt: endExclusive };
|
||||||
where.periodStart = { gte: periodStart, lte: periodEnd };
|
|
||||||
}
|
}
|
||||||
return where;
|
return where;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user