为 Web 端改版提供名称、统计和看板数据。只新增只读查询、扩展列表与详情 返回字段;写接口、托盘状态机和幂等键规则均未改动,未新增权限标识。 - 基础资料:列表补充名称与汇总,客户详情返回现场树,托盘详情返回最近流转, 托盘列表支持按流转阶段筛选和二维码精确匹配;新增现场、产线、隧道的上级 资料选项接口;Excel 导入支持 preview 只校验不写入 - 发货出库:列表支持 keyword、receiptState 筛选并补充名称、托盘数、包数和 菌种构成;详情明细补充托盘号、菌种、批次 - 首次入库:列表与详情补充名称、托盘数、托盘当前状态;新增 /summary - 托盘退回:列表支持 keyword 并补充名称与托盘汇总,详情补充托盘号和隧道; 新增 /summary、/site-options - 工厂装填:列表支持 keyword 并补充托盘号、仓库、操作人;新增 /summary、 /batches、/batches/detail - 客户现场使用:列表支持 keyword、fifoSkipped 并补充名称和被跳过的先到托盘号; 新增 /site-options、/site-board 以及 usage-fifo 的 /queue - 先到先用 DAO 抽出公共候选条件与排序,建议查询生成的 SQL 与改动前一致 - 新增各汇总 DAO 的单元测试,同步更新路由权限与模型字段契约测试 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
432 lines
20 KiB
Python
432 lines
20 KiB
Python
import unittest
|
|
from datetime import date, datetime, timedelta
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from sqlalchemy.dialects import mysql
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from common.vo import PageModel
|
|
from exceptions.exception import PermissionException, ServiceException
|
|
from module_admin.entity.vo.user_vo import CurrentUserModel
|
|
from module_business.dao.access_dao import AccessDao
|
|
from module_business.dao.filling_summary_dao import FillingSummaryDao
|
|
from module_business.dao.operation_dao import OperationDao
|
|
from module_business.domain.test_batch_scope import TestBatchScope
|
|
from module_business.entity.vo.operation_vo import (
|
|
FillingBatchDetailModel,
|
|
FillingBatchDetailQueryModel,
|
|
FillingBatchModel,
|
|
FillingBatchPageQueryModel,
|
|
FillingListItemModel,
|
|
FillingOverviewModel,
|
|
FillingPageQueryModel,
|
|
)
|
|
from module_business.service.filling_service import FillingService
|
|
from utils.page_util import PageUtil
|
|
|
|
|
|
def compile_sql(statement: Any) -> str:
|
|
compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})
|
|
return ' '.join(str(compiled).split()).replace('%%', '%')
|
|
|
|
|
|
def make_test_scope(batch_id: int) -> TestBatchScope:
|
|
return TestBatchScope(batch_id=batch_id, mode='TEST', status=SimpleNamespace(value='ACTIVE'), writable=True)
|
|
|
|
|
|
def result_rows(rows: list[Any]) -> MagicMock:
|
|
result = MagicMock()
|
|
result.all.return_value = rows
|
|
result.first.return_value = rows[0] if rows else None
|
|
return result
|
|
|
|
|
|
def result_scalar(value: Any) -> MagicMock:
|
|
result = MagicMock()
|
|
result.scalar_one.return_value = value
|
|
return result
|
|
|
|
|
|
def batch_row(**overrides: Any) -> SimpleNamespace:
|
|
values = {
|
|
'strain_id': 3,
|
|
'batch_no': 'B261017-01',
|
|
'strain_code': 'A15',
|
|
'strain_name': '平菇 A15',
|
|
'production_date': date(2026, 10, 16),
|
|
'expiry_date': date(2026, 11, 30),
|
|
'pallet_count': 6,
|
|
'package_sum': 600,
|
|
'last_operated_at': datetime(2026, 10, 17, 9, 10),
|
|
'last_filling_id': 88,
|
|
}
|
|
values.update(overrides)
|
|
return SimpleNamespace(**values)
|
|
|
|
|
|
def admin_user() -> CurrentUserModel:
|
|
return CurrentUserModel.model_construct(
|
|
permissions=['*:*:*'], roles=['admin'], user=SimpleNamespace(user_id=1, user_name='admin', admin=True)
|
|
)
|
|
|
|
|
|
def normal_user() -> CurrentUserModel:
|
|
return CurrentUserModel.model_construct(
|
|
permissions=['business:filling:list'],
|
|
roles=['common'],
|
|
user=SimpleNamespace(user_id=5, user_name='ln', admin=False),
|
|
)
|
|
|
|
|
|
class FillingListFilterTest(unittest.IsolatedAsyncioTestCase):
|
|
async def _capture(self, query: FillingPageQueryModel) -> str:
|
|
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate:
|
|
await OperationDao.get_filling_list(
|
|
MagicMock(spec=AsyncSession),
|
|
query,
|
|
warehouse_ids=[7],
|
|
scope=make_test_scope(4),
|
|
)
|
|
return compile_sql(paginate.await_args.args[1])
|
|
|
|
async def test_keyword_matches_filling_no_pallet_code_or_batch_within_scope(self) -> None:
|
|
sql = await self._capture(FillingPageQueryModel(keyword='B2610'))
|
|
for fragment in (
|
|
'JOIN biz_pallet ON biz_pallet.pallet_id = biz_filling_record.pallet_id',
|
|
"biz_filling_record.filling_no LIKE '%B2610%'",
|
|
"biz_pallet.pallet_code LIKE '%B2610%'",
|
|
"biz_filling_record.batch_no LIKE '%B2610%'",
|
|
'biz_filling_record.test_batch_id = 4',
|
|
'biz_filling_record.warehouse_id IN (7)',
|
|
):
|
|
self.assertIn(fragment, sql)
|
|
|
|
async def test_without_keyword_or_pallet_code_list_does_not_join_pallets(self) -> None:
|
|
sql = await self._capture(FillingPageQueryModel(batchNo='B1'))
|
|
self.assertNotIn('JOIN', sql)
|
|
|
|
def test_keyword_rejects_blank_value(self) -> None:
|
|
for model in (FillingPageQueryModel, FillingBatchPageQueryModel):
|
|
with self.subTest(model=model.__name__), self.assertRaises(ValueError):
|
|
model(keyword=' ')
|
|
|
|
|
|
class FillingListSummaryDaoTest(unittest.IsolatedAsyncioTestCase):
|
|
async def test_empty_ids_skip_database(self) -> None:
|
|
db = MagicMock(spec=AsyncSession)
|
|
self.assertEqual({}, await FillingSummaryDao.get_list_summaries(db, [], TestBatchScope.formal()))
|
|
db.execute.assert_not_called()
|
|
|
|
async def test_names_are_joined_in_same_batch_scope(self) -> None:
|
|
db = MagicMock(spec=AsyncSession)
|
|
db.execute = AsyncMock(return_value=result_rows([(31, 'TP-10401', '成品一仓', '年糕')]))
|
|
|
|
summaries = await FillingSummaryDao.get_list_summaries(db, [32, 31, 31], make_test_scope(4))
|
|
|
|
self.assertEqual(
|
|
{'pallet_code': 'TP-10401', 'warehouse_name': '成品一仓', 'operator_name': '年糕'}, summaries[31]
|
|
)
|
|
self.assertEqual({'pallet_code': None, 'warehouse_name': None, 'operator_name': None}, summaries[32])
|
|
sql = compile_sql(db.execute.await_args.args[0])
|
|
for fragment in (
|
|
'biz_filling_record.filling_id IN (31, 32)',
|
|
'biz_filling_record.test_batch_id = 4',
|
|
'biz_pallet.test_batch_id = 4',
|
|
'biz_warehouse.test_batch_id = 4',
|
|
'sys_user.user_id = biz_filling_record.operator_id',
|
|
"sys_user.del_flag = '0'",
|
|
):
|
|
self.assertIn(fragment, sql)
|
|
|
|
|
|
class FillingBatchPageDaoTest(unittest.IsolatedAsyncioTestCase):
|
|
async def test_groups_by_strain_and_batch_newest_first_within_scope(self) -> None:
|
|
db = MagicMock(spec=AsyncSession)
|
|
db.execute = AsyncMock(
|
|
side_effect=[
|
|
result_scalar(18),
|
|
result_rows([batch_row(), batch_row(batch_no='B261016-01', last_filling_id=80)]),
|
|
result_rows([(88, '1kg/包', '年糕'), (80, '1.2kg/包', None)]),
|
|
]
|
|
)
|
|
query = FillingBatchPageQueryModel(
|
|
pageNum=2, pageSize=5, keyword='A15', strainId=3, beginTime='2026-10-01', endTime='2026-10-17'
|
|
)
|
|
|
|
batches, total = await FillingSummaryDao.get_batch_page(db, query, [7, 6], make_test_scope(4))
|
|
|
|
self.assertEqual(18, total)
|
|
self.assertEqual(['B261017-01', 'B261016-01'], [batch['batch_no'] for batch in batches])
|
|
self.assertEqual(('1kg/包', '年糕'), (batches[0]['package_spec'], batches[0]['last_operator_name']))
|
|
self.assertEqual(('1.2kg/包', None), (batches[1]['package_spec'], batches[1]['last_operator_name']))
|
|
FillingBatchModel.model_validate(batches[0])
|
|
|
|
count_sql, page_sql, latest_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list)
|
|
for sql in (count_sql, page_sql):
|
|
for fragment in (
|
|
'GROUP BY biz_filling_record.strain_id, biz_filling_record.batch_no',
|
|
'biz_filling_record.test_batch_id = 4',
|
|
'biz_filling_record.warehouse_id IN (6, 7)',
|
|
"biz_filling_record.batch_no LIKE '%A15%'",
|
|
"biz_filling_record.strain_name_snapshot LIKE '%A15%'",
|
|
'biz_filling_record.strain_id = 3',
|
|
"biz_filling_record.operated_at >= '2026-10-01 00:00:00'",
|
|
"biz_filling_record.operated_at < '2026-10-18 00:00:00'",
|
|
):
|
|
self.assertIn(fragment, sql)
|
|
self.assertIn(
|
|
'ORDER BY max(biz_filling_record.operated_at) DESC, max(biz_filling_record.filling_id) DESC', page_sql
|
|
)
|
|
self.assertIn('LIMIT 5, 5', page_sql)
|
|
self.assertIn('biz_filling_record.filling_id IN (80, 88)', latest_sql)
|
|
|
|
async def test_empty_page_skips_latest_record_lookup(self) -> None:
|
|
db = MagicMock(spec=AsyncSession)
|
|
db.execute = AsyncMock(side_effect=[result_scalar(0), result_rows([])])
|
|
|
|
batches, total = await FillingSummaryDao.get_batch_page(
|
|
db, FillingBatchPageQueryModel(), [], TestBatchScope.formal()
|
|
)
|
|
|
|
self.assertEqual(([], 0), (batches, total))
|
|
self.assertEqual(2, db.execute.await_count)
|
|
self.assertIn('false', compile_sql(db.execute.await_args_list[0].args[0]).lower())
|
|
|
|
|
|
class FillingBatchDetailDaoTest(unittest.IsolatedAsyncioTestCase):
|
|
async def test_missing_batch_returns_none_without_further_queries(self) -> None:
|
|
db = MagicMock(spec=AsyncSession)
|
|
db.execute = AsyncMock(return_value=result_rows([]))
|
|
|
|
self.assertIsNone(await FillingSummaryDao.get_batch_detail(db, 3, 'B1', [7], TestBatchScope.formal()))
|
|
self.assertEqual(1, db.execute.await_count)
|
|
|
|
async def test_distribution_uses_active_cycle_and_closed_cycles_count_as_consumed(self) -> None:
|
|
db = MagicMock(spec=AsyncSession)
|
|
recent = [
|
|
(90, 'FL-90', 501, 'TP-10501', '成品一仓', '李娜', datetime(2026, 10, 17, 9, 10), '1kg/包'),
|
|
(89, 'FL-89', 500, None, None, None, datetime(2026, 10, 17, 9, 5), '0.8kg/包'),
|
|
]
|
|
db.execute = AsyncMock(
|
|
side_effect=[
|
|
result_rows([batch_row(pallet_count=30)]),
|
|
result_rows([('AT_SITE_UNUSED', 15), (None, 2), ('IN_TRANSIT', 8), ('FILLED_IN_WAREHOUSE', 5)]),
|
|
result_rows(recent),
|
|
]
|
|
)
|
|
|
|
detail = await FillingSummaryDao.get_batch_detail(db, 3, 'B261017-01', [7], make_test_scope(4))
|
|
|
|
self.assertEqual(
|
|
[('AT_SITE_UNUSED', 15), ('IN_TRANSIT', 8), ('FILLED_IN_WAREHOUSE', 5), ('CONSUMED', 2)],
|
|
[(item['state'], item['pallet_count']) for item in detail['distribution']],
|
|
)
|
|
self.assertEqual(['TP-10501', None], [item['pallet_code'] for item in detail['recent']])
|
|
# 包装规格与操作人取批次内最新一条记录
|
|
self.assertEqual(('1kg/包', '李娜'), (detail['package_spec'], detail['last_operator_name']))
|
|
FillingBatchDetailModel.model_validate(detail)
|
|
|
|
header_sql, distribution_sql, recent_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list)
|
|
for sql in (header_sql, distribution_sql, recent_sql):
|
|
for fragment in (
|
|
'biz_filling_record.strain_id = 3',
|
|
"biz_filling_record.batch_no = 'B261017-01'",
|
|
'biz_filling_record.warehouse_id IN (7)',
|
|
'biz_filling_record.test_batch_id = 4',
|
|
):
|
|
self.assertIn(fragment, sql)
|
|
self.assertIn('biz_pallet_inventory.active_filling_id = biz_filling_record.filling_id', distribution_sql)
|
|
self.assertIn('biz_pallet_inventory.test_batch_id = 4', distribution_sql)
|
|
self.assertIn('ORDER BY biz_filling_record.operated_at DESC, biz_filling_record.filling_id DESC', recent_sql)
|
|
self.assertIn('LIMIT 10', recent_sql)
|
|
|
|
|
|
class FillingOverviewDaoTest(unittest.IsolatedAsyncioTestCase):
|
|
def _db(self) -> MagicMock:
|
|
db = MagicMock(spec=AsyncSession)
|
|
db.execute = AsyncMock(
|
|
side_effect=[
|
|
result_rows([(3, 'B261017-01', '平菇 A15', 'A15', 6, 600)]),
|
|
result_rows(
|
|
[
|
|
(3, 'B261017-01', '平菇 A15', 'A15', 6, 600),
|
|
(3, 'B261016-01', '平菇 A15', 'A15', 10, 1000),
|
|
(4, 'B261015-01', '白灵菇 B22', 'B22', 27, 2700),
|
|
(5, 'B261010-01', None, 'S130', 18, 1800),
|
|
(6, 'B261008-01', '金针菇 J61', 'J61', 8, 800),
|
|
]
|
|
),
|
|
result_rows([(6, '周转仓', 10), (4, '成品一仓', 13)]),
|
|
result_rows([('B260930-01', date(2026, 10, 28), 3), ('B260923-01', date(2026, 10, 25), 4)]),
|
|
]
|
|
)
|
|
return db
|
|
|
|
async def test_overview_totals_top_strains_empty_pallets_and_nearest_expiry(self) -> None:
|
|
db = self._db()
|
|
|
|
overview = await FillingSummaryDao.get_overview(
|
|
db,
|
|
[4, 6],
|
|
make_test_scope(4),
|
|
datetime(2026, 10, 17),
|
|
datetime(2026, 10, 1),
|
|
date(2026, 10, 31),
|
|
14,
|
|
)
|
|
|
|
self.assertEqual(
|
|
(6, 600, 1), (overview['today_pallet_count'], overview['today_package_sum'], overview['today_batch_count'])
|
|
)
|
|
self.assertEqual((69, 5), (overview['month_pallet_count'], overview['month_batch_count']))
|
|
self.assertEqual(
|
|
[('白灵菇 B22', 27), ('S130', 18), ('平菇 A15', 16)],
|
|
[(item['strain_name'], item['pallet_count']) for item in overview['month_top_strains']],
|
|
)
|
|
self.assertEqual(23, overview['empty_pallet_count'])
|
|
self.assertEqual([4, 6], [row['warehouse_id'] for row in overview['empty_by_warehouse']])
|
|
self.assertEqual((7, 2), (overview['near_expiry_pallet_count'], overview['near_expiry_batch_count']))
|
|
self.assertEqual(
|
|
('B260923-01', date(2026, 10, 25)), (overview['nearest_expiry_batch_no'], overview['nearest_expiry_date'])
|
|
)
|
|
FillingOverviewModel.model_validate(overview)
|
|
|
|
today_sql, month_sql, empty_sql, near_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list)
|
|
self.assertIn("biz_filling_record.operated_at >= '2026-10-17 00:00:00'", today_sql)
|
|
self.assertIn("biz_filling_record.operated_at >= '2026-10-01 00:00:00'", month_sql)
|
|
for sql in (today_sql, month_sql):
|
|
self.assertIn('biz_filling_record.warehouse_id IN (4, 6)', sql)
|
|
self.assertIn('biz_filling_record.test_batch_id = 4', sql)
|
|
self.assertIn("biz_pallet_inventory.flow_state = 'EMPTY_IN_WAREHOUSE'", empty_sql)
|
|
self.assertIn("biz_pallet_inventory.flow_state = 'FILLED_IN_WAREHOUSE'", near_sql)
|
|
self.assertIn("biz_filling_record.expiry_date <= '2026-10-31'", near_sql)
|
|
self.assertIn('biz_filling_record.filling_id = biz_pallet_inventory.active_filling_id', near_sql)
|
|
for sql in (empty_sql, near_sql):
|
|
self.assertIn('biz_pallet_inventory.warehouse_id IN (4, 6)', sql)
|
|
self.assertIn('biz_pallet_inventory.test_batch_id = 4', sql)
|
|
|
|
async def test_user_without_warehouses_sees_nothing(self) -> None:
|
|
db = MagicMock(spec=AsyncSession)
|
|
db.execute = AsyncMock(side_effect=[result_rows([]), result_rows([]), result_rows([]), result_rows([])])
|
|
|
|
overview = await FillingSummaryDao.get_overview(
|
|
db, [], TestBatchScope.formal(), datetime(2026, 10, 17), datetime(2026, 10, 1), date(2026, 10, 31), 14
|
|
)
|
|
|
|
self.assertEqual(0, overview['month_pallet_count'])
|
|
self.assertIsNone(overview['nearest_expiry_date'])
|
|
FillingOverviewModel.model_validate(overview)
|
|
for call in db.execute.await_args_list:
|
|
self.assertIn('false', compile_sql(call.args[0]).lower())
|
|
|
|
|
|
class FillingServiceSummaryTest(unittest.IsolatedAsyncioTestCase):
|
|
async def test_list_rows_carry_names_in_camel_case(self) -> None:
|
|
record = {
|
|
'filling_id': 31,
|
|
'filling_no': 'FL-1',
|
|
'pallet_id': 501,
|
|
'warehouse_id': 4,
|
|
'strain_id': 3,
|
|
'strain_code_snapshot': 'A15',
|
|
'strain_name_snapshot': '平菇 A15',
|
|
'batch_no': 'B1',
|
|
'package_spec': '1kg/包',
|
|
'initial_packages': 100,
|
|
'idempotency_key': 'k',
|
|
'request_hash': 'h',
|
|
'operator_id': 2,
|
|
'operated_at': datetime(2026, 10, 17, 9, 10),
|
|
}
|
|
page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False)
|
|
summary = {31: {'pallet_code': 'TP-10501', 'warehouse_name': '成品一仓', 'operator_name': '年糕'}}
|
|
with (
|
|
patch.object(OperationDao, 'get_filling_list', new=AsyncMock(return_value=page)),
|
|
patch.object(FillingSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries,
|
|
):
|
|
result = await FillingService.get_filling_list_services(
|
|
MagicMock(spec=AsyncSession), FillingPageQueryModel(), admin_user(), TestBatchScope.formal()
|
|
)
|
|
|
|
self.assertEqual([31], summaries.await_args.args[1])
|
|
self.assertIsInstance(result.rows[0], FillingListItemModel)
|
|
dumped = result.rows[0].model_dump(by_alias=True)
|
|
self.assertEqual(
|
|
('TP-10501', '成品一仓', '年糕'), (dumped['palletCode'], dumped['warehouseName'], dumped['operatorName'])
|
|
)
|
|
|
|
async def test_batches_page_uses_warehouse_scope_and_reports_has_next(self) -> None:
|
|
batch = {**vars(batch_row()), 'package_spec': '1kg/包', 'last_operator_name': '年糕'}
|
|
query = FillingBatchPageQueryModel(pageNum=1, pageSize=1)
|
|
with (
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 4, 7])),
|
|
patch.object(FillingSummaryDao, 'get_batch_page', new=AsyncMock(return_value=([batch], 3))) as get_page,
|
|
):
|
|
result = await FillingService.get_filling_batches_services(
|
|
MagicMock(spec=AsyncSession), query, normal_user(), TestBatchScope.formal()
|
|
)
|
|
|
|
self.assertEqual([4, 7], get_page.await_args.args[2])
|
|
self.assertEqual((3, True), (result.total, result.has_next))
|
|
row = result.rows[0].model_dump(by_alias=True)
|
|
self.assertEqual(('B261017-01', 600, '年糕'), (row['batchNo'], row['packageSum'], row['lastOperatorName']))
|
|
self.assertNotIn('lastFillingId', row)
|
|
|
|
async def test_missing_batch_is_hidden_for_normal_user_and_precise_for_admin(self) -> None:
|
|
query = FillingBatchDetailQueryModel(strainId=3, batchNo='B1')
|
|
cases = (
|
|
(normal_user(), PermissionException, '无权访问该生产批次'),
|
|
(admin_user(), ServiceException, '生产批次不存在'),
|
|
)
|
|
for user, error, message in cases:
|
|
with (
|
|
self.subTest(admin=user.user.admin),
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
|
patch.object(FillingSummaryDao, 'get_batch_detail', new=AsyncMock(return_value=None)) as get_detail,
|
|
self.assertRaises(error) as raised,
|
|
):
|
|
await FillingService.get_filling_batch_detail_services(
|
|
MagicMock(spec=AsyncSession), query, user, TestBatchScope.formal()
|
|
)
|
|
self.assertEqual(message, raised.exception.message)
|
|
self.assertEqual((3, 'B1'), get_detail.await_args.args[1:3])
|
|
|
|
async def test_overview_passes_today_month_and_near_expiry_bounds(self) -> None:
|
|
overview = {
|
|
'today_pallet_count': 0,
|
|
'today_package_sum': 0,
|
|
'today_batch_count': 0,
|
|
'month_start': date(2026, 10, 1),
|
|
'month_pallet_count': 0,
|
|
'month_batch_count': 0,
|
|
'month_top_strains': [],
|
|
'empty_pallet_count': 0,
|
|
'empty_by_warehouse': [],
|
|
'near_expiry_days': 14,
|
|
'near_expiry_pallet_count': 0,
|
|
'near_expiry_batch_count': 0,
|
|
'nearest_expiry_batch_no': None,
|
|
'nearest_expiry_date': None,
|
|
}
|
|
with (
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
|
patch.object(FillingSummaryDao, 'get_overview', new=AsyncMock(return_value=overview)) as get_overview,
|
|
):
|
|
result = await FillingService.get_filling_overview_services(
|
|
MagicMock(spec=AsyncSession), normal_user(), TestBatchScope.formal()
|
|
)
|
|
|
|
self.assertIsInstance(result, FillingOverviewModel)
|
|
_, warehouse_ids, _, today_start, month_start, near_until, days = get_overview.await_args.args
|
|
self.assertEqual([7], warehouse_ids)
|
|
self.assertEqual((0, 0), (today_start.hour, today_start.minute))
|
|
self.assertEqual((1, today_start.month), (month_start.day, month_start.month))
|
|
self.assertEqual(today_start.date() + timedelta(days=14), near_until)
|
|
self.assertEqual(14, days)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|