diff --git a/app.go b/app.go index 82e957f..554af5e 100644 --- a/app.go +++ b/app.go @@ -111,6 +111,10 @@ func (a *App) GetItemDetail(accountID uint, assetID string) (*inventory.ItemDeta return a.svc.Inventory().GetItemDetail(accountID, assetID) } +func (a *App) GetDailyBuys(accountID uint, page, pageSize int) (*inventory.DailyBuyPaginated, error) { + return a.svc.Inventory().ListDailyBuys(accountID, page, pageSize) +} + func (a *App) GetCompletedTrades(accountID uint, page, pageSize int, sortBy, sortDir string) (*trade.PaginatedGroups, error) { return a.svc.Trade().ListCompletedTradeGroups(accountID, page, pageSize, sortBy, sortDir) } @@ -132,6 +136,10 @@ func (a *App) GetUnmatchedSells(accountID uint) ([]model.TradeRecord, error) { return a.svc.Trade().ListUnmatchedSells(accountID) } +func (a *App) GetDailySells(accountID uint, year, month, page, pageSize int) (*trade.DailySellPaginated, error) { + return a.svc.Trade().ListDailySells(accountID, year, month, page, pageSize) +} + func (a *App) GetPnlSummary(accountID uint) (*pnl.PnlSummaryView, error) { return a.svc.Pnl().GetSummary(accountID) } diff --git a/frontend/src/hooks/useDailyBuys.ts b/frontend/src/hooks/useDailyBuys.ts new file mode 100644 index 0000000..ee603a8 --- /dev/null +++ b/frontend/src/hooks/useDailyBuys.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; +import { GetDailyBuys } from '../lib/wails'; +import { useAccounts } from './useAccounts'; + +const STALE_TIME_MS = 2 * 60 * 1000; +const DEFAULT_PAGE_SIZE = 30; + +export function useDailyBuys( + selectedAccountId: number | null, + page: number, + pageSize: number = DEFAULT_PAGE_SIZE, +) { + const { data: accounts = [] } = useAccounts(); + + return useQuery({ + queryKey: ['dailyBuys', selectedAccountId ?? 0, page, pageSize], + queryFn: () => { + const accountId = selectedAccountId ?? 0; + return GetDailyBuys(accountId, page, pageSize); + }, + staleTime: STALE_TIME_MS, + enabled: selectedAccountId !== null || accounts.length > 0, + }); +} diff --git a/frontend/src/hooks/useDailySells.ts b/frontend/src/hooks/useDailySells.ts new file mode 100644 index 0000000..31b524b --- /dev/null +++ b/frontend/src/hooks/useDailySells.ts @@ -0,0 +1,26 @@ +import { useQuery } from '@tanstack/react-query'; +import { GetDailySells } from '../lib/wails'; +import { useAccounts } from './useAccounts'; + +const STALE_TIME_MS = 2 * 60 * 1000; +const DEFAULT_PAGE_SIZE = 30; + +export function useDailySells( + selectedAccountId: number | null, + year: number, + month: number, + page: number, + pageSize: number = DEFAULT_PAGE_SIZE, +) { + const { data: accounts = [] } = useAccounts(); + + return useQuery({ + queryKey: ['dailySells', selectedAccountId ?? 0, year, month, page, pageSize], + queryFn: () => { + const accountId = selectedAccountId ?? 0; + return GetDailySells(accountId, year, month, page, pageSize); + }, + staleTime: STALE_TIME_MS, + enabled: selectedAccountId !== null || accounts.length > 0, + }); +} diff --git a/frontend/src/hooks/useExpandableSet.ts b/frontend/src/hooks/useExpandableSet.ts new file mode 100644 index 0000000..7f3c3ad --- /dev/null +++ b/frontend/src/hooks/useExpandableSet.ts @@ -0,0 +1,25 @@ +import { useState, useCallback } from 'react'; + +/** + * Shared hook for expand/collapse state using a Set of string keys. + * Used by collapsible day cards in daily sell/buy views. + */ +export function useExpandableSet() { + const [expanded, setExpanded] = useState>(new Set()); + + const toggle = useCallback((key: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); + + const isExpanded = useCallback((key: string) => expanded.has(key), [expanded]); + + return { expanded, isExpanded, toggle }; +} diff --git a/frontend/src/lib/wails.ts b/frontend/src/lib/wails.ts index 8fcf25f..435b2f2 100644 --- a/frontend/src/lib/wails.ts +++ b/frontend/src/lib/wails.ts @@ -10,6 +10,8 @@ import { GetCompletedTrades, GetCompletedTradesSummary, GetUnmatchedSells, + GetDailySells, + GetDailyBuys, GetPnlSummary, GetMonthlyBreakdown, GetDashboardSummary, @@ -34,6 +36,8 @@ export { GetCompletedTrades, GetCompletedTradesSummary, GetUnmatchedSells, + GetDailySells, + GetDailyBuys, GetPnlSummary, GetMonthlyBreakdown, GetDashboardSummary, diff --git a/frontend/src/pages/CompletedTradesPage.tsx b/frontend/src/pages/CompletedTradesPage.tsx index e1781e1..b974304 100644 --- a/frontend/src/pages/CompletedTradesPage.tsx +++ b/frontend/src/pages/CompletedTradesPage.tsx @@ -28,6 +28,7 @@ import TableCell from '@mui/material/TableCell'; import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TablePagination from '@mui/material/TablePagination'; +import TextField from '@mui/material/TextField'; import TableRow from '@mui/material/TableRow'; import TableSortLabel from '@mui/material/TableSortLabel'; import Paper from '@mui/material/Paper'; @@ -45,8 +46,11 @@ import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { useCompletedTrades } from '../hooks/useCompletedTrades'; import { useCompletedTradesSummary } from '../hooks/useCompletedTradesSummary'; import { useUnmatchedSells } from '../hooks/useUnmatchedSells'; +import { useDailySells } from '../hooks/useDailySells'; +import { useExpandableSet } from '../hooks/useExpandableSet'; import { useUIStore } from '../store/uiStore'; import { formatCNY, plHexColor } from '../lib/format'; +import { platformLabel } from '../lib/constants'; import { BrowserOpenURL } from '../../wailsjs/runtime/runtime'; import type { model, trade } from '../lib/wails'; @@ -57,7 +61,7 @@ declare module '@tanstack/react-table' { } } -type TabKey = 'completed' | 'unmatched'; +type TabKey = 'completed' | 'unmatched' | 'dailySell'; interface GroupedTrade { itemName: string; @@ -1168,6 +1172,418 @@ function UnmatchedSellsContent({ ); } +// ─── Daily Sells Tab Content ────────────────────────────────────────────────── + +const SKELETON_COUNT = 5; +const PAGE_SIZE = 12; + +function DailySellsContent({ accountId }: { accountId: number | null }) { + const [dismissed, setDismissed] = useState(false); + const [jumpPage, setJumpPage] = useState(''); + + const [page, setPage] = useState(0); + + const { + data: paginated, + isLoading, + error, + refetch, + } = useDailySells(accountId, 0, 0, page + 1, PAGE_SIZE); + const { isExpanded, toggle } = useExpandableSet(); + + const months = useMemo(() => paginated?.months ?? [], [paginated?.months]); + const totalMonths = paginated?.total ?? 0; + + if (isLoading) { + return ( + + {Array.from({ length: SKELETON_COUNT }, (_, i) => ( + + ))} + + ); + } + + if (error && !dismissed) { + return ( + + { + setDismissed(false); + void refetch(); + }} + onDismiss={() => setDismissed(true)} + /> + + ); + } + + return ( + + {months.length === 0 && ( + } + title="暂无卖出记录" + description="同步账户数据后将在此显示每日卖出详情。" + /> + )} + + {months.length > 0 && ( + + + + + + {months.map((m) => { + const monthExpanded = isExpanded(m.month); + const mLabel = m.month.replace('-', '年') + '月'; + const netPl = m.totalProfit - m.totalFee; + + return ( + + toggle(m.month)} + > + + + {monthExpanded ? ( + + ) : ( + + )} + + + + + + {mLabel} + + + 卖出 {m.totalCount} 件 + + + 利润{' '} + + {formatCNY(m.totalProfit)} + + + + 手续费 {formatCNY(m.totalFee)} + + + 净利{' '} + + {formatCNY(netPl)} + + + + + + + + + + {m.dayGroups.map((group) => { + const expanded = isExpanded(group.date); + return ( + + toggle(group.date)} + > + + + {expanded ? ( + + ) : ( + + )} + + + + + {(() => { + const d = new Date(group.date); + return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`; + })()} + + + {group.dayOfWeek} + + + + + 卖出 {group.totalCount} 件 + + + + 利润{' '} + + {formatCNY(group.totalProfit)} + + + + 手续费 {formatCNY(group.totalFee)} + + + 净利{' '} + + {formatCNY(group.totalProfit - group.totalFee)} + + + {(() => { + const buyCost = group.items.reduce( + (s, i) => s + i.buyPrice * i.quantity, + 0, + ); + const rate = + buyCost > 0 + ? (group.totalProfit / buyCost) * 100 + : 0; + return ( + + 盈亏率{' '} + + {rate >= 0 ? '+' : ''} + {rate.toFixed(1)}% + + + ); + })()} + + + + + + + +
+ + + + 物品 + + + 数量 + + + 买入价 + + + 卖出价 + + + 手续费 + + + 利润 + + + 利润率 + + + 平台 + + + + + {group.items.map((item, idx) => { + const costBasis = item.buyPrice * item.quantity; + const profitRate = + costBasis > 0 + ? (item.profit / costBasis) * 100 + : 0; + return ( + + + + {item.itemName} + {item.exterior + ? ` (${item.exterior})` + : ''} + + + + + {item.quantity} + + + + + {formatCNY(item.buyPrice)} + + + + + {formatCNY(item.sellPrice)} + + + + + {formatCNY(item.totalFee)} + + + + + {formatCNY(item.profit)} + + + + + {profitRate >= 0 ? '+' : ''} + {profitRate.toFixed(1)}% + + + + + {platformLabel[item.platform] ?? + item.platform} + + + + ); + })} + +
+
+ + + + + ); + })} + + + + + + ); + })} + + + + + + setPage(p)} + rowsPerPageOptions={[12]} + labelRowsPerPage="每页" + /> + setJumpPage(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && jumpPage) { + const p = Math.max( + 1, + Math.min(Math.ceil(totalMonths / PAGE_SIZE), Number(jumpPage)), + ); + setPage(p - 1); + setJumpPage(''); + } + }} + sx={{ width: 70 }} + inputProps={{ min: 1, style: { textAlign: 'center', padding: '4px 8px' } }} + /> + + + )} + + ); +} + // ─── Page ──────────────────────────────────────────────────────────────────── export default function CompletedTradesPage() { @@ -1191,6 +1607,7 @@ export default function CompletedTradesPage() { setTab(v as TabKey)} sx={{ mb: 1 }}> + {tab === 'completed' && ( @@ -1199,6 +1616,7 @@ export default function CompletedTradesPage() { {tab === 'unmatched' && ( )} + {tab === 'dailySell' && } ); } diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index 94d3f89..96bb5a1 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -9,6 +9,7 @@ import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import TableSortLabel from '@mui/material/TableSortLabel'; import TablePagination from '@mui/material/TablePagination'; +import TextField from '@mui/material/TextField'; import Paper from '@mui/material/Paper'; import Chip from '@mui/material/Chip'; import IconButton from '@mui/material/IconButton'; @@ -34,7 +35,12 @@ import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import Tooltip from '@mui/material/Tooltip'; +import Tabs from '@mui/material/Tabs'; +import Tab from '@mui/material/Tab'; +import ReceiptIcon from '@mui/icons-material/Receipt'; import type { model } from '../lib/wails'; +import { useDailyBuys } from '../hooks/useDailyBuys'; +import { useExpandableSet } from '../hooks/useExpandableSet'; declare module '@tanstack/react-table' { // eslint-disable-next-line @typescript-eslint/no-unused-vars interface ColumnMeta { @@ -207,6 +213,488 @@ const groupedColumns: ColumnDef[] = [ }, ]; +const SKELETON_COUNT = 5; +const PAGE_SIZE = 12; + +const dailyBuyStatusLabel: Record = { + in_inventory: '持有中', + listed: '已上架', +}; + +const dailyBuyStatusColor = (status: string): 'success' | 'warning' | 'default' => + status === 'listed' ? 'warning' : 'success'; + +function DailyBuysContent({ accountId }: { accountId: number | null }) { + const [dismissed, setDismissed] = useState(false); + const [jumpPage, setJumpPage] = useState(''); + + const [page, setPage] = useState(0); + + const { + data: paginated, + isLoading, + error, + refetch, + } = useDailyBuys(accountId, page + 1, PAGE_SIZE); + const { isExpanded, toggle } = useExpandableSet(); + + const months = useMemo(() => paginated?.months ?? [], [paginated?.months]); + const totalMonths = paginated?.total ?? 0; + + if (isLoading) { + return ( + + {Array.from({ length: SKELETON_COUNT }, (_, i) => ( + + ))} + + ); + } + + if (error && !dismissed) { + return ( + + { + setDismissed(false); + void refetch(); + }} + onDismiss={() => setDismissed(true)} + /> + + ); + } + + return ( + + {months.length === 0 && ( + } + title="暂无买入记录" + description="同步账户数据后将在此显示每日买入详情。" + /> + )} + + {months.length > 0 && ( + + + + + + {months.map((m) => { + const monthExpanded = isExpanded(m.month); + const pl = m.totalMarketValue != null ? m.totalMarketValue - m.totalCost : null; + const plRate = m.totalCost > 0 && pl != null ? (pl / m.totalCost) * 100 : null; + const mLabel = m.month.replace('-', '年') + '月'; + + return ( + + toggle(m.month)} + > + + + {monthExpanded ? ( + + ) : ( + + )} + + + + + + {mLabel} + + + {m.totalCount} 件 + + 成本 {formatCNY(m.totalCost)} + {m.totalMarketValue != null && pl != null && ( + <> + + 市值 {formatCNY(m.totalMarketValue)} + + + 盈亏{' '} + + {formatCNY(pl)} + + + {plRate != null && ( + + 盈亏率{' '} + + {plRate >= 0 ? '+' : ''} + {plRate.toFixed(1)}% + + + )} + + )} + + + + + + + + {m.dayGroups.map((group) => { + const expanded = isExpanded(group.date); + return ( + + toggle(group.date)} + > + + + {expanded ? ( + + ) : ( + + )} + + + + + {(() => { + const d = new Date(group.date); + return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`; + })()} + + + {group.dayOfWeek} + + + + + 买入 {group.totalCount} 件 + + + + 成本 {formatCNY(group.totalCost)} + + {group.totalMarketValue != null ? ( + <> + + 市值{' '} + + {formatCNY(group.totalMarketValue)} + + + + 浮动盈亏{' '} + + {formatCNY( + group.totalMarketValue - group.totalCost, + )} + + + {group.totalCost > 0 && ( + + 盈亏率{' '} + + {((group.totalMarketValue - group.totalCost) / + group.totalCost) * + 100 >= + 0 + ? '+' + : ''} + {( + ((group.totalMarketValue - + group.totalCost) / + group.totalCost) * + 100 + ).toFixed(1)} + % + + + )} + + ) : null} + + + + + + + +
+ + + + 物品 + + + 数量 + + + 买入价 + + + 总额 + + + 当前市价 + + + 浮动盈亏 + + + 浮动率 + + + 平台 + + + 状态 + + + + + {group.items.map((item, idx) => { + const upl = item.unrealizedPl; + const uplRate = + item.totalCost > 0 && upl != null + ? (upl / item.totalCost) * 100 + : null; + return ( + + + + {item.itemName} + {item.exterior + ? ` (${item.exterior})` + : ''} + + + + + {item.quantity} + + + + + {formatCNY(item.buyPrice)} + + + + + {formatCNY(item.totalCost)} + + + + + {item.marketPrice != null + ? formatCNY(item.marketPrice) + : '--'} + + + + {upl != null ? ( + + {formatCNY(upl)} + + ) : ( + + -- + + )} + + + {uplRate != null ? ( + + {uplRate >= 0 ? '+' : ''} + {uplRate.toFixed(1)}% + + ) : ( + + -- + + )} + + + + {platformLabel[item.platform] ?? + item.platform} + + + + + + + ); + })} + +
+
+ + + + + ); + })} + + + + + + ); + })} + + + + + + setPage(p)} + rowsPerPageOptions={[12]} + labelRowsPerPage="每页" + /> + setJumpPage(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && jumpPage) { + const p = Math.max( + 1, + Math.min(Math.ceil(totalMonths / PAGE_SIZE), Number(jumpPage)), + ); + setPage(p - 1); + setJumpPage(''); + } + }} + sx={{ width: 70 }} + inputProps={{ min: 1, style: { textAlign: 'center', padding: '4px 8px' } }} + /> + + + )} + + ); +} + export default function InventoryPage() { const navigate = useNavigate(); const [dismissed, setDismissed] = useState(false); @@ -246,6 +734,8 @@ export default function InventoryPage() { return result; }, [groups, globalFilter]); + const [tab, setTab] = useState<'list' | 'dailyBuy'>('list'); + const [expandedNames, setExpandedNames] = useState>(new Set()); const toggle = (name: string) => { @@ -294,7 +784,14 @@ export default function InventoryPage() { - {!selectedAccountId && groups.length === 0 && !isLoading && ( + setTab(v as 'list' | 'dailyBuy')} sx={{ mb: 1 }}> + + + + + {tab === 'dailyBuy' && } + + {tab === 'list' && !selectedAccountId && groups.length === 0 && !isLoading && ( } @@ -310,7 +807,7 @@ export default function InventoryPage() { )} - {isLoading && ( + {tab === 'list' && isLoading && ( {[1, 2, 3, 4, 5].map((i) => ( @@ -318,7 +815,7 @@ export default function InventoryPage() { )} - {error && !dismissed && ( + {tab === 'list' && error && !dismissed && ( )} - {!isLoading && !error && selectedAccountId && groups.length === 0 && ( + {tab === 'list' && !isLoading && !error && selectedAccountId && groups.length === 0 && ( } @@ -341,17 +838,21 @@ export default function InventoryPage() { )} - {!isLoading && !error && groups.length > 0 && filteredGroups.length === 0 && ( - - } - title="无匹配物品" - description="请尝试更改类型筛选或搜索条件。" - /> - - )} + {tab === 'list' && + !isLoading && + !error && + groups.length > 0 && + filteredGroups.length === 0 && ( + + } + title="无匹配物品" + description="请尝试更改类型筛选或搜索条件。" + /> + + )} - {!isLoading && !error && filteredGroups.length > 0 && ( + {tab === 'list' && !isLoading && !error && filteredGroups.length > 0 && ( diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index e481319..d0d06b7 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/theme.ts","./src/components/AddAccountDialog.tsx","./src/components/AppLayout.tsx","./src/components/Dialog.tsx","./src/components/EmptyState.tsx","./src/components/ErrorBanner.tsx","./src/components/ErrorBoundary.tsx","./src/components/PageSearchBar.tsx","./src/components/PnlSummaryCards.tsx","./src/components/SortableTable.tsx","./src/hooks/useAccounts.ts","./src/hooks/useBillRecords.ts","./src/hooks/useCompletedTrades.ts","./src/hooks/useCompletedTradesSummary.ts","./src/hooks/useCreateAccount.ts","./src/hooks/useDashboard.ts","./src/hooks/useDeleteAccount.ts","./src/hooks/useInventory.ts","./src/hooks/useItemDetail.ts","./src/hooks/useMarketPrices.ts","./src/hooks/useMonthlyBreakdown.ts","./src/hooks/usePnlSummary.ts","./src/hooks/useRentalHistory.ts","./src/hooks/useSyncAccount.ts","./src/hooks/useUnmatchedSells.ts","./src/hooks/useUpdateAccount.ts","./src/lib/constants.ts","./src/lib/format.ts","./src/lib/wails.ts","./src/pages/AccountsPage.tsx","./src/pages/BillPage.tsx","./src/pages/CompletedTradesPage.tsx","./src/pages/DashboardPage.tsx","./src/pages/InventoryDetailPage.tsx","./src/pages/InventoryPage.tsx","./src/pages/PnLPage.tsx","./src/pages/SettingsPage.tsx","./src/store/uiStore.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/theme.ts","./src/components/AddAccountDialog.tsx","./src/components/AppLayout.tsx","./src/components/Dialog.tsx","./src/components/EmptyState.tsx","./src/components/ErrorBanner.tsx","./src/components/ErrorBoundary.tsx","./src/components/PageSearchBar.tsx","./src/components/PnlSummaryCards.tsx","./src/components/SortableTable.tsx","./src/hooks/useAccounts.ts","./src/hooks/useBillRecords.ts","./src/hooks/useCompletedTrades.ts","./src/hooks/useCompletedTradesSummary.ts","./src/hooks/useCreateAccount.ts","./src/hooks/useDailyBuys.ts","./src/hooks/useDailySells.ts","./src/hooks/useDashboard.ts","./src/hooks/useDeleteAccount.ts","./src/hooks/useExpandableSet.ts","./src/hooks/useInventory.ts","./src/hooks/useItemDetail.ts","./src/hooks/useMarketPrices.ts","./src/hooks/useMonthlyBreakdown.ts","./src/hooks/usePnlSummary.ts","./src/hooks/useRentalHistory.ts","./src/hooks/useSyncAccount.ts","./src/hooks/useUnmatchedSells.ts","./src/hooks/useUpdateAccount.ts","./src/lib/constants.ts","./src/lib/format.ts","./src/lib/wails.ts","./src/pages/AccountsPage.tsx","./src/pages/BillPage.tsx","./src/pages/CompletedTradesPage.tsx","./src/pages/DashboardPage.tsx","./src/pages/InventoryDetailPage.tsx","./src/pages/InventoryPage.tsx","./src/pages/PnLPage.tsx","./src/pages/SettingsPage.tsx","./src/store/uiStore.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index bb2c9de..3f5ac8d 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -23,6 +23,10 @@ export function GetCompletedTrades(arg1:number,arg2:number,arg3:number,arg4:stri export function GetCompletedTradesSummary(arg1:number):Promise; +export function GetDailyBuys(arg1:number,arg2:number,arg3:number):Promise; + +export function GetDailySells(arg1:number,arg2:number,arg3:number,arg4:number,arg5:number):Promise; + export function GetDashboardSummary():Promise; export function GetInventory(arg1:number,arg2:string,arg3:string,arg4:number,arg5:number,arg6:string,arg7:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 7fcf08c..606e719 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -30,6 +30,14 @@ export function GetCompletedTradesSummary(arg1) { return window['go']['main']['App']['GetCompletedTradesSummary'](arg1); } +export function GetDailyBuys(arg1, arg2, arg3) { + return window['go']['main']['App']['GetDailyBuys'](arg1, arg2, arg3); +} + +export function GetDailySells(arg1, arg2, arg3, arg4, arg5) { + return window['go']['main']['App']['GetDailySells'](arg1, arg2, arg3, arg4, arg5); +} + export function GetDashboardSummary() { return window['go']['main']['App']['GetDashboardSummary'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index bd9b20c..58d31e1 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -37,6 +37,149 @@ export namespace bill { export namespace inventory { + export class DailyBuyItem { + itemName: string; + exterior: string; + quantity: number; + buyPrice: number; + totalCost: number; + marketPrice?: number; + unrealizedPl?: number; + platform: string; + status: string; + + static createFrom(source: any = {}) { + return new DailyBuyItem(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.itemName = source["itemName"]; + this.exterior = source["exterior"]; + this.quantity = source["quantity"]; + this.buyPrice = source["buyPrice"]; + this.totalCost = source["totalCost"]; + this.marketPrice = source["marketPrice"]; + this.unrealizedPl = source["unrealizedPl"]; + this.platform = source["platform"]; + this.status = source["status"]; + } + } + export class DailyBuyGroup { + date: string; + dayOfWeek: string; + items: DailyBuyItem[]; + totalCount: number; + totalCost: number; + totalMarketValue?: number; + + static createFrom(source: any = {}) { + return new DailyBuyGroup(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.date = source["date"]; + this.dayOfWeek = source["dayOfWeek"]; + this.items = this.convertValues(source["items"], DailyBuyItem); + this.totalCount = source["totalCount"]; + this.totalCost = source["totalCost"]; + this.totalMarketValue = source["totalMarketValue"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + + export class DailyBuyMonthGroup { + month: string; + dayGroups: DailyBuyGroup[]; + totalCount: number; + totalCost: number; + totalMarketValue?: number; + + static createFrom(source: any = {}) { + return new DailyBuyMonthGroup(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.month = source["month"]; + this.dayGroups = this.convertValues(source["dayGroups"], DailyBuyGroup); + this.totalCount = source["totalCount"]; + this.totalCost = source["totalCost"]; + this.totalMarketValue = source["totalMarketValue"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class DailyBuyPaginated { + months: DailyBuyMonthGroup[]; + total: number; + page: number; + pageSize: number; + + static createFrom(source: any = {}) { + return new DailyBuyPaginated(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.months = this.convertValues(source["months"], DailyBuyMonthGroup); + this.total = source["total"]; + this.page = source["page"]; + this.pageSize = source["pageSize"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class InventoryGroup { itemName: string; exterior: string; @@ -834,6 +977,147 @@ export namespace trade { this.totalNetPl = source["totalNetPl"]; } } + export class DailySellItem { + itemName: string; + exterior: string; + quantity: number; + buyPrice: number; + sellPrice: number; + totalFee: number; + profit: number; + platform: string; + + static createFrom(source: any = {}) { + return new DailySellItem(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.itemName = source["itemName"]; + this.exterior = source["exterior"]; + this.quantity = source["quantity"]; + this.buyPrice = source["buyPrice"]; + this.sellPrice = source["sellPrice"]; + this.totalFee = source["totalFee"]; + this.profit = source["profit"]; + this.platform = source["platform"]; + } + } + export class DailySellGroup { + date: string; + dayOfWeek: string; + items: DailySellItem[]; + totalCount: number; + totalProfit: number; + totalFee: number; + + static createFrom(source: any = {}) { + return new DailySellGroup(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.date = source["date"]; + this.dayOfWeek = source["dayOfWeek"]; + this.items = this.convertValues(source["items"], DailySellItem); + this.totalCount = source["totalCount"]; + this.totalProfit = source["totalProfit"]; + this.totalFee = source["totalFee"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + + export class DailySellMonthGroup { + month: string; + dayGroups: DailySellGroup[]; + totalCount: number; + totalProfit: number; + totalFee: number; + + static createFrom(source: any = {}) { + return new DailySellMonthGroup(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.month = source["month"]; + this.dayGroups = this.convertValues(source["dayGroups"], DailySellGroup); + this.totalCount = source["totalCount"]; + this.totalProfit = source["totalProfit"]; + this.totalFee = source["totalFee"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class DailySellPaginated { + months: DailySellMonthGroup[]; + total: number; + page: number; + pageSize: number; + + static createFrom(source: any = {}) { + return new DailySellPaginated(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.months = this.convertValues(source["months"], DailySellMonthGroup); + this.total = source["total"]; + this.page = source["page"]; + this.pageSize = source["pageSize"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class TradeGroup { itemName: string; exterior: string; diff --git a/pkg/model/inventory.go b/pkg/model/inventory.go index 5a83ae4..c423bba 100644 --- a/pkg/model/inventory.go +++ b/pkg/model/inventory.go @@ -2,6 +2,11 @@ package model import "gorm.io/gorm" +const ( + InventoryStatusInInventory = "in_inventory" + InventoryStatusListed = "listed" +) + type InventoryItem struct { gorm.Model CS2Item `gorm:"embedded"` diff --git a/pkg/orm/interfaces.go b/pkg/orm/interfaces.go index 677cd6d..3d00415 100644 --- a/pkg/orm/interfaces.go +++ b/pkg/orm/interfaces.go @@ -31,6 +31,7 @@ type TradeInterface interface { FindCompletedTradeGroupKeys(accountID uint, offset, limit int, sortBy, sortDir string) ([]InventoryGroupKey, int64, error) FindSellsByGroupKeys(accountID uint, keys []InventoryGroupKey) ([]model.TradeRecord, error) FindTradeRecordsByIDs(ids []uint) ([]model.TradeRecord, error) + FindDailySells(accountID uint, year, month int) ([]DailySellRow, error) } type InventoryGroupKey struct { @@ -45,6 +46,7 @@ type InventoryInterface interface { FindInventoryByAssetID(accountID uint, assetID string) (*model.InventoryItem, error) FindInventoryGroupKeys(accountID uint, status, weaponType string, offset, limit int, sortBy, sortDir string) ([]InventoryGroupKey, int64, error) FindInventoryByGroupKeys(accountID uint, keys []InventoryGroupKey) ([]model.InventoryItem, error) + FindDailyBuys(accountID uint) ([]DailyBuyRow, error) } type PnlInterface interface { diff --git a/pkg/orm/inventory.go b/pkg/orm/inventory.go index 31a891f..0025514 100644 --- a/pkg/orm/inventory.go +++ b/pkg/orm/inventory.go @@ -10,6 +10,19 @@ import ( "github.com/CsJsss/CS2Ledger/pkg/model" ) +// DailyBuyRow is a denormalized row for daily-buy queries, assembled from an inventory item and its buy trade. +type DailyBuyRow struct { + ItemName string + Exterior string + Quantity int64 + BuyPrice int64 + BuyAt int64 + Source string + Status string + MarketHashName string + CsqaqGoodsID int +} + func (o *ormImpl) UpsertInventory(item *model.InventoryItem) error { return o.db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "account_id"}, {Name: "asset_id"}}, @@ -82,6 +95,39 @@ func (o *ormImpl) FindInventoryGroupKeys(accountID uint, status, weaponType stri // FindInventoryByGroupKeys returns inventory items matching the given (item_name, exterior) pairs. // Pass accountID=0 to query across all accounts. +func (o *ormImpl) FindDailyBuys(accountID uint) ([]DailyBuyRow, error) { + q := o.db.Model(&model.InventoryItem{}). + Where("status IN ?", []string{model.InventoryStatusInInventory, model.InventoryStatusListed}). + Preload("BuyTrade") + if accountID != 0 { + q = q.Where("account_id = ?", accountID) + } + + var items []model.InventoryItem + if err := q.Order("updated_at DESC").Find(&items).Error; err != nil { + return nil, err + } + + rows := make([]DailyBuyRow, 0, len(items)) + for _, it := range items { + if it.BuyTrade == nil { + continue + } + rows = append(rows, DailyBuyRow{ + ItemName: it.ItemName, + Exterior: it.Exterior, + Quantity: it.Quantity, + BuyPrice: it.BuyTrade.UnitPrice, + BuyAt: it.BuyTrade.TradeAt, + Source: it.BuyTrade.Source, + Status: it.Status, + MarketHashName: it.MarketHashName, + CsqaqGoodsID: it.CsqaqGoodsID, + }) + } + return rows, nil +} + func (o *ormImpl) FindInventoryByGroupKeys(accountID uint, keys []InventoryGroupKey) ([]model.InventoryItem, error) { if len(keys) == 0 { return nil, nil diff --git a/pkg/orm/trade.go b/pkg/orm/trade.go index e2404a9..1b5ab2b 100644 --- a/pkg/orm/trade.go +++ b/pkg/orm/trade.go @@ -2,12 +2,27 @@ package orm import ( "strings" + "time" "gorm.io/gorm" "github.com/CsJsss/CS2Ledger/pkg/model" ) +// DailySellRow is a denormalized row for daily-sell queries, assembled from a matched sell+buy pair. +type DailySellRow struct { + SellID uint + ItemName string + Exterior string + Quantity int64 + SellPrice int64 + SellFee int64 + SellAt int64 + Source string + BuyPrice int64 + BuyFee int64 +} + func (o *ormImpl) CreateTrade(t *model.TradeRecord) error { t.ItemName = strings.TrimSpace(t.ItemName) if t.ExternalID == "" { @@ -211,3 +226,72 @@ func (o *ormImpl) FindTradeRecordsByIDs(ids []uint) ([]model.TradeRecord, error) err := o.db.Where("id IN ?", ids).Find(&records).Error return records, err } + +func (o *ormImpl) FindDailySells(accountID uint, year, month int) ([]DailySellRow, error) { + // Step 1: query matched sells. + q := o.db.Model(&model.TradeRecord{}). + Where("trade_type = ? AND matched_buy_trade_id IS NOT NULL", model.DirectionSell) + if accountID != 0 { + q = q.Where("account_id = ?", accountID) + } + if year > 0 && month > 0 { + start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC).UnixMilli() + end := time.Date(year, time.Month(month+1), 1, 0, 0, 0, 0, time.UTC).UnixMilli() + q = q.Where("trade_at >= ? AND trade_at < ?", start, end) + } + + var sells []model.TradeRecord + if err := q.Order("trade_at DESC").Find(&sells).Error; err != nil { + return nil, err + } + if len(sells) == 0 { + return nil, nil + } + + // Step 2: collect matched buy IDs. + buyIDs := make([]uint, 0, len(sells)) + for _, s := range sells { + if s.MatchedBuyTradeID != nil { + buyIDs = append(buyIDs, *s.MatchedBuyTradeID) + } + } + if len(buyIDs) == 0 { + return nil, nil + } + + // Step 3: query buys by IDs. + var buys []model.TradeRecord + if err := o.db.Model(&model.TradeRecord{}). + Where("id IN ?", buyIDs).Find(&buys).Error; err != nil { + return nil, err + } + buyMap := make(map[uint]*model.TradeRecord, len(buys)) + for i := range buys { + buyMap[buys[i].ID] = &buys[i] + } + + // Step 4: assemble rows in Go. + rows := make([]DailySellRow, 0, len(sells)) + for _, s := range sells { + if s.MatchedBuyTradeID == nil { + continue + } + b, ok := buyMap[*s.MatchedBuyTradeID] + if !ok { + continue + } + rows = append(rows, DailySellRow{ + SellID: s.ID, + ItemName: s.ItemName, + Exterior: s.Exterior, + Quantity: s.Quantity, + SellPrice: s.UnitPrice, + SellFee: s.Fee, + SellAt: s.TradeAt, + Source: s.Source, + BuyPrice: b.UnitPrice, + BuyFee: b.Fee, + }) + } + return rows, nil +} diff --git a/pkg/service/inventory/service.go b/pkg/service/inventory/service.go index 764c35b..5d4c3e0 100644 --- a/pkg/service/inventory/service.go +++ b/pkg/service/inventory/service.go @@ -9,6 +9,7 @@ import ( "github.com/CsJsss/CS2Ledger/pkg/model" "github.com/CsJsss/CS2Ledger/pkg/orm" "github.com/CsJsss/CS2Ledger/pkg/platform" + "github.com/CsJsss/CS2Ledger/pkg/utils/dateutil" "github.com/CsJsss/CS2Ledger/pkg/utils/logfx" ) @@ -69,6 +70,7 @@ type InventoryInterface interface { List(accountID uint, status string) ([]model.InventoryItem, error) GetItemDetail(accountID uint, assetID string) (*ItemDetail, error) ListGroups(accountID uint, status, weaponType string, page, pageSize int, sortBy, sortDir string) (*PaginatedGroups, error) + ListDailyBuys(accountID uint, page, pageSize int) (*DailyBuyPaginated, error) SetPriceProvider(p PriceProvider) SetPriceSource(source string) } @@ -99,6 +101,32 @@ func (s *service) SetPriceSource(source string) { s.priceSource = source } +// resolvePriceMap fetches market prices and returns a map from MarketHashName to price in fen. +// Returns nil if prices are unavailable (best-effort). +func (s *service) resolvePriceMap() map[string]int64 { + if s.prices == nil { + return nil + } + priceList, err := s.prices.GetAllPrices() + if err != nil { + return nil + } + priceMap := make(map[string]int64, len(priceList)) + for _, p := range priceList { + var mp int64 + switch s.priceSource { + case "youpin": + mp = int64(p.YoupinPrice * 100) + case "steam": + mp = int64(p.SteamPrice * 100) + default: + mp = int64(p.BuffPrice * 100) + } + priceMap[p.MarketHashName] = mp + } + return priceMap +} + func (s *service) List(accountID uint, status string) ([]model.InventoryItem, error) { return s.orm.FindInventoryByAccount(accountID, status) } @@ -293,6 +321,123 @@ func (s *service) sortGroups(groups []InventoryGroup, sortBy, sortDir string) { }) } +func (s *service) ListDailyBuys(accountID uint, page, pageSize int) (*DailyBuyPaginated, error) { + rows, err := s.orm.FindDailyBuys(accountID) + if err != nil { + return nil, err + } + + priceMap := s.resolvePriceMap() + + type dateKey string + byDate := make(map[dateKey][]DailyBuyItem) + for _, r := range rows { + date, _ := dateutil.FormatTimestamp(r.BuyAt) + dk := dateKey(date) + totalCost := r.BuyPrice * r.Quantity + item := DailyBuyItem{ + ItemName: r.ItemName, + Exterior: r.Exterior, + Quantity: r.Quantity, + BuyPrice: r.BuyPrice, + TotalCost: totalCost, + Platform: r.Source, + Status: r.Status, + } + if priceMap != nil { + if mp, ok := priceMap[r.MarketHashName]; ok { + item.MarketPrice = &mp + upl := (mp - r.BuyPrice) * r.Quantity + item.UnrealizedPl = &upl + } + } + byDate[dk] = append(byDate[dk], item) + } + + groups := make([]DailyBuyGroup, 0, len(byDate)) + for dk, items := range byDate { + var totalCost int64 + var totalMV int64 + hasMV := true + for _, it := range items { + totalCost += it.TotalCost + if it.MarketPrice != nil { + totalMV += *it.MarketPrice * it.Quantity + } else { + hasMV = false + } + } + t, _ := dateutil.ParseDate(string(dk)) + g := DailyBuyGroup{ + Date: string(dk), + DayOfWeek: dateutil.DayOfWeekNames[t.Weekday()], + Items: items, + TotalCount: len(items), + TotalCost: totalCost, + } + if hasMV && len(items) > 0 { + g.TotalMarketValue = &totalMV + } + groups = append(groups, g) + } + sort.Slice(groups, func(i, j int) bool { return groups[i].Date > groups[j].Date }) + + // Group by month + type monthKey string + byMonth := make(map[monthKey][]DailyBuyGroup) + for _, g := range groups { + mk := monthKey(g.Date[:7]) + byMonth[mk] = append(byMonth[mk], g) + } + + // Build month groups + months := make([]DailyBuyMonthGroup, 0, len(byMonth)) + for mk, dayGroups := range byMonth { + var tc int64 + var totalCost int64 + var totalMV int64 + hasMV := true + for _, dg := range dayGroups { + tc += int64(dg.TotalCount) + totalCost += dg.TotalCost + if dg.TotalMarketValue != nil { + totalMV += *dg.TotalMarketValue + } else { + hasMV = false + } + } + mg := DailyBuyMonthGroup{ + Month: string(mk), + DayGroups: dayGroups, + TotalCount: int(tc), + TotalCost: totalCost, + } + if hasMV { + mg.TotalMarketValue = &totalMV + } + months = append(months, mg) + } + sort.Slice(months, func(i, j int) bool { return months[i].Month > months[j].Month }) + + // Paginate by months + total := int64(len(months)) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 50 { + pageSize = 12 + } + offset := (page - 1) * pageSize + if offset >= len(months) { + return &DailyBuyPaginated{Months: nil, Total: total, Page: page, PageSize: pageSize}, nil + } + end := offset + pageSize + if end > len(months) { + end = len(months) + } + return &DailyBuyPaginated{Months: months[offset:end], Total: total, Page: page, PageSize: pageSize}, nil +} + func (s *service) GetItemDetail(accountID uint, assetID string) (*ItemDetail, error) { item, err := s.orm.FindInventoryByAssetID(accountID, assetID) if err != nil || item == nil { @@ -333,6 +478,42 @@ type RentalSummary struct { RentCount int `json:"rentCount"` } +type DailyBuyItem struct { + ItemName string `json:"itemName"` + Exterior string `json:"exterior"` + Quantity int64 `json:"quantity"` + BuyPrice int64 `json:"buyPrice"` + TotalCost int64 `json:"totalCost"` + MarketPrice *int64 `json:"marketPrice,omitempty"` + UnrealizedPl *int64 `json:"unrealizedPl,omitempty"` + Platform string `json:"platform"` + Status string `json:"status"` +} + +type DailyBuyGroup struct { + Date string `json:"date"` + DayOfWeek string `json:"dayOfWeek"` + Items []DailyBuyItem `json:"items"` + TotalCount int `json:"totalCount"` + TotalCost int64 `json:"totalCost"` + TotalMarketValue *int64 `json:"totalMarketValue,omitempty"` +} + +type DailyBuyMonthGroup struct { + Month string `json:"month"` + DayGroups []DailyBuyGroup `json:"dayGroups"` + TotalCount int `json:"totalCount"` + TotalCost int64 `json:"totalCost"` + TotalMarketValue *int64 `json:"totalMarketValue,omitempty"` +} + +type DailyBuyPaginated struct { + Months []DailyBuyMonthGroup `json:"months"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + var Module = fx.Module("inventory", logfx.WithComponent("inventory"), fx.Provide( diff --git a/pkg/service/trade/service.go b/pkg/service/trade/service.go index 5a27534..32358b6 100644 --- a/pkg/service/trade/service.go +++ b/pkg/service/trade/service.go @@ -9,6 +9,7 @@ import ( "github.com/CsJsss/CS2Ledger/pkg/model" "github.com/CsJsss/CS2Ledger/pkg/orm" "github.com/CsJsss/CS2Ledger/pkg/platform" + "github.com/CsJsss/CS2Ledger/pkg/utils/dateutil" "github.com/CsJsss/CS2Ledger/pkg/utils/logfx" ) @@ -94,6 +95,41 @@ type CompletedTradesSummary struct { TotalNetPl int64 `json:"totalNetPl"` } +type DailySellItem struct { + ItemName string `json:"itemName"` + Exterior string `json:"exterior"` + Quantity int64 `json:"quantity"` + BuyPrice int64 `json:"buyPrice"` + SellPrice int64 `json:"sellPrice"` + TotalFee int64 `json:"totalFee"` + Profit int64 `json:"profit"` + Platform string `json:"platform"` +} + +type DailySellGroup struct { + Date string `json:"date"` + DayOfWeek string `json:"dayOfWeek"` + Items []DailySellItem `json:"items"` + TotalCount int `json:"totalCount"` + TotalProfit int64 `json:"totalProfit"` + TotalFee int64 `json:"totalFee"` +} + +type DailySellMonthGroup struct { + Month string `json:"month"` + DayGroups []DailySellGroup `json:"dayGroups"` + TotalCount int `json:"totalCount"` + TotalProfit int64 `json:"totalProfit"` + TotalFee int64 `json:"totalFee"` +} + +type DailySellPaginated struct { + Months []DailySellMonthGroup `json:"months"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + type TradeInterface interface { ListByAccount(accountID uint, tradeType string) ([]model.TradeRecord, error) ListCompletedTrades(accountID uint) ([]CompletedTradeView, error) @@ -102,6 +138,7 @@ type TradeInterface interface { ListUnmatchedSells(accountID uint) ([]model.TradeRecord, error) SetPriceProvider(p PriceProvider) SetPriceSource(source string) + ListDailySells(accountID uint, year, month int, page, pageSize int) (*DailySellPaginated, error) } type service struct { @@ -394,6 +431,95 @@ func (svc *service) GetCompletedTradesSummary(accountID uint) (*CompletedTradesS return sum, nil } +func (svc *service) ListDailySells(accountID uint, year, month int, page, pageSize int) (*DailySellPaginated, error) { + rows, err := svc.orm.FindDailySells(accountID, year, month) + if err != nil { + return nil, err + } + + type dateKey string + byDate := make(map[dateKey][]DailySellItem) + for _, r := range rows { + date, _ := dateutil.FormatTimestamp(r.SellAt) + dk := dateKey(date) + profit := (r.SellPrice-r.BuyPrice)*r.Quantity - (r.SellFee + r.BuyFee) + byDate[dk] = append(byDate[dk], DailySellItem{ + ItemName: r.ItemName, + Exterior: r.Exterior, + Quantity: r.Quantity, + BuyPrice: r.BuyPrice, + SellPrice: r.SellPrice, + TotalFee: r.SellFee + r.BuyFee, + Profit: profit, + Platform: r.Source, + }) + } + + groups := make([]DailySellGroup, 0, len(byDate)) + for dk, items := range byDate { + var totalProfit, totalFee int64 + for _, it := range items { + totalProfit += it.Profit + totalFee += it.TotalFee + } + t, _ := dateutil.ParseDate(string(dk)) + groups = append(groups, DailySellGroup{ + Date: string(dk), + DayOfWeek: dateutil.DayOfWeekNames[t.Weekday()], + Items: items, + TotalCount: len(items), + TotalProfit: totalProfit, + TotalFee: totalFee, + }) + } + sort.Slice(groups, func(i, j int) bool { return groups[i].Date > groups[j].Date }) + + // Group by month + type monthKey string + byMonth := make(map[monthKey][]DailySellGroup) + for _, g := range groups { + mk := monthKey(g.Date[:7]) + byMonth[mk] = append(byMonth[mk], g) + } + + // Build month groups + months := make([]DailySellMonthGroup, 0, len(byMonth)) + for mk, dayGroups := range byMonth { + var tc, tp, tf int64 + for _, dg := range dayGroups { + tc += int64(dg.TotalCount) + tp += dg.TotalProfit + tf += dg.TotalFee + } + months = append(months, DailySellMonthGroup{ + Month: string(mk), + DayGroups: dayGroups, + TotalCount: int(tc), + TotalProfit: tp, + TotalFee: tf, + }) + } + sort.Slice(months, func(i, j int) bool { return months[i].Month > months[j].Month }) + + // Paginate by months + total := int64(len(months)) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 50 { + pageSize = 12 + } + offset := (page - 1) * pageSize + if offset >= len(months) { + return &DailySellPaginated{Months: nil, Total: total, Page: page, PageSize: pageSize}, nil + } + end := offset + pageSize + if end > len(months) { + end = len(months) + } + return &DailySellPaginated{Months: months[offset:end], Total: total, Page: page, PageSize: pageSize}, nil +} + var Module = fx.Module("trade", logfx.WithComponent("trade"), fx.Provide( diff --git a/pkg/utils/dateutil/dateutil.go b/pkg/utils/dateutil/dateutil.go new file mode 100644 index 0000000..87205bc --- /dev/null +++ b/pkg/utils/dateutil/dateutil.go @@ -0,0 +1,22 @@ +// Package dateutil provides shared date formatting and weekday mapping for the application. +package dateutil + +import "time" + +// DateFormat is the standard date string format used across services. +const DateFormat = "2006-01-02" + +// DayOfWeekNames maps time.Weekday (0=Sunday) to Chinese weekday names. +var DayOfWeekNames = [...]string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"} + +// FormatTimestamp converts a Unix-millisecond timestamp to a date string and Chinese weekday name. +func FormatTimestamp(tsMillis int64) (date string, dayOfWeek string) { + t := time.UnixMilli(tsMillis) + return t.Format(DateFormat), DayOfWeekNames[t.Weekday()] +} + +// ParseDate parses a DateFormat-formatted string into a time.Time. +// It always succeeds for strings produced by FormatTimestamp. +func ParseDate(date string) (time.Time, error) { + return time.Parse(DateFormat, date) +}